@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,145 @@
1
+ /**
2
+ * Inbound file transport. Discord messages can carry attachments; this turns
3
+ * them into AG-UI multimodal message content the agent's model can read —
4
+ * images as binary data parts and plain text/CSV/JSON/etc. as decoded `text`
5
+ * parts — downloading each from its public CDN URL (no auth required).
6
+ *
7
+ * The AgentContentPart union is intentionally identical to bot-slack's so the
8
+ * agent sees the same multimodal input shape across both adapters.
9
+ */
10
+ const DEFAULT_MAX_BYTES = 10_000_000; // 10 MiB
11
+ const DEFAULT_MAX_TEXT_BYTES = 200_000; // ~200 KiB
12
+ const DEFAULT_MAX_FILES = 5;
13
+ /** File extensions that indicate decodable plain text even when MIME is absent. */
14
+ const TEXT_EXT_RE = /\.(txt|csv|json|md|log|tsv|ya?ml)$/i;
15
+ function isTextMime(mime) {
16
+ return (mime.startsWith("text/") ||
17
+ mime === "application/json" ||
18
+ mime === "application/csv" ||
19
+ mime === "application/xml" ||
20
+ mime === "application/x-ndjson" ||
21
+ mime === "application/yaml");
22
+ }
23
+ function mediaPartType(mime) {
24
+ if (mime.startsWith("image/"))
25
+ return "image";
26
+ if (mime.startsWith("audio/"))
27
+ return "audio";
28
+ if (mime.startsWith("video/"))
29
+ return "video";
30
+ if (mime === "application/pdf")
31
+ return "document";
32
+ return null;
33
+ }
34
+ /**
35
+ * Download Discord message attachments and convert them to AG-UI content parts.
36
+ *
37
+ * Files whose reported size exceeds `maxBytes` are skipped WITHOUT fetching;
38
+ * the actual downloaded byte length is re-checked against the same cap after
39
+ * fetching. Images map to an image part; text/* MIME types and text-named
40
+ * files (.txt, .csv, .json, .md, .log, .tsv, .yaml) — including ones reported
41
+ * with a generic binary MIME like application/octet-stream — map to a text
42
+ * part (truncated to `maxTextBytes`); other binary content is skipped with a
43
+ * short text note explaining why (bot-slack parity), so the model knows a file
44
+ * was dropped.
45
+ *
46
+ * At most `maxFiles` attachments (default 5) are processed; any beyond that cap
47
+ * are ignored without fetching, bounding the multimodal payload a single
48
+ * message can inject.
49
+ */
50
+ export async function buildFileContentParts(attachments, cfg = {}) {
51
+ const fetchImpl = cfg.fetchImpl ?? fetch;
52
+ const maxBytes = cfg.maxBytes ?? DEFAULT_MAX_BYTES;
53
+ const maxTextBytes = cfg.maxTextBytes ?? DEFAULT_MAX_TEXT_BYTES;
54
+ const maxFiles = cfg.maxFiles ?? DEFAULT_MAX_FILES;
55
+ const parts = [];
56
+ // Cap the number of attachments processed per message before any fetching,
57
+ // bounding the multimodal payload / token budget (bot-slack parity).
58
+ for (const att of attachments.slice(0, maxFiles)) {
59
+ // Gate on the reported size — skip (without fetching) if it exceeds the cap.
60
+ // Surface the skip as a note so the model knows a file was dropped and why
61
+ // (bot-slack parity).
62
+ if (att.size > maxBytes) {
63
+ parts.push({
64
+ type: "text",
65
+ text: `[attachment "${att.name}" skipped: ~${Math.round(att.size / 1e6)} MB exceeds the ${Math.round(maxBytes / 1e6)} MB limit]`,
66
+ });
67
+ continue;
68
+ }
69
+ const mime = (att.contentType ?? "").toLowerCase().split(";")[0].trim();
70
+ const media = mediaPartType(mime);
71
+ const textMime = isTextMime(mime);
72
+ // Treat a recognized text extension as text whenever the MIME isn't a
73
+ // medium we pass through and isn't itself a text MIME — this also covers
74
+ // generic/unrecognized binary types (e.g. application/octet-stream) that
75
+ // Discord sometimes reports for .csv/.json/etc. uploads.
76
+ const textName = !media && !textMime && TEXT_EXT_RE.test(att.name);
77
+ // Skip binary types we don't represent — surface as a note.
78
+ if (!media && !textMime && !textName) {
79
+ parts.push({
80
+ type: "text",
81
+ text: `[attachment "${att.name}" skipped: unsupported type ${mime || "unknown"}]`,
82
+ });
83
+ continue;
84
+ }
85
+ let res;
86
+ try {
87
+ res = await fetchImpl(att.url);
88
+ }
89
+ catch {
90
+ parts.push({
91
+ type: "text",
92
+ text: `[attachment "${att.name}" skipped: download failed]`,
93
+ });
94
+ continue;
95
+ }
96
+ if (!res.ok) {
97
+ parts.push({
98
+ type: "text",
99
+ text: `[attachment "${att.name}" skipped: download failed]`,
100
+ });
101
+ continue;
102
+ }
103
+ const bytes = new Uint8Array(await res.arrayBuffer());
104
+ // Double-check the actual downloaded size — the reported `att.size` can
105
+ // lie (or be absent), so re-gate on the real byte length.
106
+ if (bytes.length > maxBytes) {
107
+ parts.push({
108
+ type: "text",
109
+ text: `[attachment "${att.name}" skipped: ~${Math.round(bytes.length / 1e6)} MB exceeds the ${Math.round(maxBytes / 1e6)} MB limit]`,
110
+ });
111
+ continue;
112
+ }
113
+ if (media) {
114
+ // Binary media part — base64-encoded, exact same shape as bot-slack.
115
+ const effectiveMime = mime || `application/octet-stream`;
116
+ parts.push({
117
+ type: media,
118
+ source: {
119
+ type: "data",
120
+ value: Buffer.from(bytes).toString("base64"),
121
+ mimeType: effectiveMime,
122
+ },
123
+ });
124
+ }
125
+ else {
126
+ // Text content — truncate the BYTES then decode (slicing the decoded
127
+ // string by character index would corrupt multi-byte UTF-8 and wouldn't
128
+ // actually bound the byte length). Append a note when truncated.
129
+ let buf = bytes;
130
+ let truncated = false;
131
+ if (buf.length > maxTextBytes) {
132
+ buf = buf.subarray(0, maxTextBytes);
133
+ truncated = true;
134
+ }
135
+ const text = new TextDecoder("utf-8").decode(buf);
136
+ parts.push({
137
+ type: "text",
138
+ text: `Attached file "${att.name}" (${mime || "text"}${truncated ? ", truncated" : ""}):\n` +
139
+ text +
140
+ (truncated ? "\n…(truncated)" : ""),
141
+ });
142
+ }
143
+ }
144
+ return parts;
145
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=download-files.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"download-files.test.d.ts","sourceRoot":"","sources":["../src/download-files.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,181 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { buildFileContentParts } from "./download-files.js";
3
+ describe("buildFileContentParts", () => {
4
+ it("turns an image attachment into an image content part", async () => {
5
+ const fetchImpl = vi.fn(async () => ({
6
+ ok: true,
7
+ arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
8
+ }));
9
+ const parts = await buildFileContentParts([
10
+ {
11
+ url: "https://cdn/x.png",
12
+ name: "x.png",
13
+ contentType: "image/png",
14
+ size: 3,
15
+ },
16
+ ], { fetchImpl });
17
+ expect(parts[0]).toMatchObject({ type: "image" });
18
+ });
19
+ it("decodes a text/csv attachment into a text content part", async () => {
20
+ const csv = "a,b\n1,2";
21
+ const fetchImpl = vi.fn(async () => ({
22
+ ok: true,
23
+ arrayBuffer: async () => new TextEncoder().encode(csv).buffer,
24
+ }));
25
+ const parts = await buildFileContentParts([
26
+ {
27
+ url: "https://cdn/x.csv",
28
+ name: "x.csv",
29
+ contentType: "text/csv",
30
+ size: csv.length,
31
+ },
32
+ ], { fetchImpl });
33
+ expect(parts[0]).toMatchObject({ type: "text" });
34
+ expect(JSON.stringify(parts[0])).toContain("a,b");
35
+ });
36
+ it("skips a file over the size cap without fetching (notes the skip)", async () => {
37
+ const fetchImpl = vi.fn();
38
+ const parts = await buildFileContentParts([
39
+ {
40
+ url: "https://cdn/big.bin",
41
+ name: "big.bin",
42
+ contentType: "application/octet-stream",
43
+ size: 99_000_000,
44
+ },
45
+ ], { fetchImpl, maxBytes: 10_000_000 });
46
+ // The file is gated pre-fetch but surfaced to the model as a skip note.
47
+ expect(parts).toHaveLength(1);
48
+ const note = parts[0];
49
+ expect(note.type).toBe("text");
50
+ expect(note.text).toContain("skipped");
51
+ expect(note.text).toContain("big.bin");
52
+ expect(fetchImpl).not.toHaveBeenCalled();
53
+ });
54
+ it("skips a file whose downloaded body exceeds the cap (reported size lied)", async () => {
55
+ // Reported size is under the cap, so it gets fetched — but the actual body
56
+ // is larger than maxBytes and must be dropped post-fetch.
57
+ const big = new Uint8Array(100);
58
+ const fetchImpl = vi.fn(async () => ({
59
+ ok: true,
60
+ arrayBuffer: async () => big.buffer,
61
+ }));
62
+ const parts = await buildFileContentParts([
63
+ {
64
+ url: "https://cdn/x.png",
65
+ name: "x.png",
66
+ contentType: "image/png",
67
+ size: 3,
68
+ },
69
+ ], { fetchImpl, maxBytes: 10 });
70
+ expect(fetchImpl).toHaveBeenCalledOnce();
71
+ // Dropped post-fetch but surfaced to the model as a skip note.
72
+ expect(parts).toHaveLength(1);
73
+ const note = parts[0];
74
+ expect(note.type).toBe("text");
75
+ expect(note.text).toContain("skipped");
76
+ expect(note.text).toContain("x.png");
77
+ });
78
+ it("truncates a text body larger than maxTextBytes", async () => {
79
+ // Use a payload char that never appears in the surrounding framing so the
80
+ // count of retained body bytes is unambiguous.
81
+ const big = "Z".repeat(500);
82
+ const fetchImpl = vi.fn(async () => ({
83
+ ok: true,
84
+ arrayBuffer: async () => new TextEncoder().encode(big).buffer,
85
+ }));
86
+ const parts = await buildFileContentParts([
87
+ {
88
+ url: "https://cdn/big.log",
89
+ name: "big.log",
90
+ contentType: "text/plain",
91
+ size: big.length,
92
+ },
93
+ ], { fetchImpl, maxTextBytes: 100 });
94
+ expect(parts[0]).toMatchObject({ type: "text" });
95
+ const text = parts[0].text;
96
+ expect(text).toContain("truncated");
97
+ expect(text).toContain("…(truncated)");
98
+ // Exactly 100 body bytes retained out of the original 500.
99
+ expect(text.match(/Z/g)?.length).toBe(100);
100
+ });
101
+ it("processes at most maxFiles attachments per message (default 5)", async () => {
102
+ const fetchImpl = vi.fn(async () => ({
103
+ ok: true,
104
+ arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
105
+ }));
106
+ // 7 attachments, default maxFiles is 5 → only the first 5 are processed.
107
+ const attachments = Array.from({ length: 7 }, (_, i) => ({
108
+ url: `https://cdn/x${i}.png`,
109
+ name: `x${i}.png`,
110
+ contentType: "image/png",
111
+ size: 3,
112
+ }));
113
+ const parts = await buildFileContentParts(attachments, { fetchImpl });
114
+ expect(parts).toHaveLength(5);
115
+ expect(fetchImpl).toHaveBeenCalledTimes(5);
116
+ });
117
+ it("surfaces an oversize skip as a note while still emitting a co-attached valid image", async () => {
118
+ const pngBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
119
+ const fetchImpl = vi.fn(async () => ({
120
+ ok: true,
121
+ arrayBuffer: async () => pngBytes.buffer,
122
+ }));
123
+ const parts = await buildFileContentParts([
124
+ {
125
+ url: "https://cdn/big.png",
126
+ name: "big.png",
127
+ contentType: "image/png",
128
+ size: 50, // exceeds the tiny maxBytes below
129
+ },
130
+ {
131
+ url: "https://cdn/ok.png",
132
+ name: "ok.png",
133
+ contentType: "image/png",
134
+ size: pngBytes.length,
135
+ },
136
+ ], { fetchImpl, maxBytes: 10 });
137
+ // The oversize file becomes a skip note; the valid one still becomes image.
138
+ const note = parts.find((p) => p.type === "text");
139
+ expect(note).toBeDefined();
140
+ expect(note.text).toContain("skipped");
141
+ expect(note.text).toContain("big.png");
142
+ expect(parts.some((p) => p.type === "image")).toBe(true);
143
+ // Only the valid file was fetched — the oversize one was gated pre-fetch.
144
+ expect(fetchImpl).toHaveBeenCalledOnce();
145
+ });
146
+ it("surfaces an unsupported binary type as a note", async () => {
147
+ const fetchImpl = vi.fn();
148
+ const parts = await buildFileContentParts([
149
+ {
150
+ url: "https://cdn/app.bin",
151
+ name: "app.bin",
152
+ contentType: "application/octet-stream",
153
+ size: 3,
154
+ },
155
+ ], { fetchImpl });
156
+ expect(parts).toHaveLength(1);
157
+ const note = parts[0];
158
+ expect(note.type).toBe("text");
159
+ expect(note.text).toContain("unsupported type");
160
+ expect(note.text).toContain("app.bin");
161
+ // Unsupported types are gated pre-fetch.
162
+ expect(fetchImpl).not.toHaveBeenCalled();
163
+ });
164
+ it("decodes a .csv reported as application/octet-stream as a text part", async () => {
165
+ const csv = "a,b\n1,2";
166
+ const fetchImpl = vi.fn(async () => ({
167
+ ok: true,
168
+ arrayBuffer: async () => new TextEncoder().encode(csv).buffer,
169
+ }));
170
+ const parts = await buildFileContentParts([
171
+ {
172
+ url: "https://cdn/data.csv",
173
+ name: "data.csv",
174
+ contentType: "application/octet-stream",
175
+ size: csv.length,
176
+ },
177
+ ], { fetchImpl });
178
+ expect(parts[0]).toMatchObject({ type: "text" });
179
+ expect(JSON.stringify(parts[0])).toContain("a,b");
180
+ });
181
+ });
@@ -0,0 +1,36 @@
1
+ import type { RunRenderer } from "@copilotkit/channels";
2
+ /**
3
+ * Minimal structural type for a Discord text-channel (or thread) so unit
4
+ * tests can inject a fake without opening a Gateway connection.
5
+ */
6
+ export interface ChannelLike {
7
+ sendTyping(): Promise<void>;
8
+ send(payload: string | {
9
+ content: string;
10
+ }): Promise<{
11
+ id: string;
12
+ edit(p: string | {
13
+ content: string;
14
+ }): Promise<unknown>;
15
+ }>;
16
+ }
17
+ /**
18
+ * Construct a {@link RunRenderer} for a single agent run in Discord.
19
+ *
20
+ * The `subscriber` is passed to `runAgent`. After the run resolves, the
21
+ * run-loop reads `getCapturedToolCalls()` (filtering by which tools are
22
+ * registered) and `getPendingInterrupt()`. `markInterrupted()` is called
23
+ * when a new turn arrives for the same conversation while this run is
24
+ * still streaming — it appends a `_(interrupted)_` marker to any partial
25
+ * reply so the user sees a clear visual cue that the bot stopped.
26
+ */
27
+ export declare function createRunRenderer(args: {
28
+ channel: ChannelLike;
29
+ /**
30
+ * Custom-event names that should be treated as interrupts — captured
31
+ * for later dispatch to an `InterruptHandler`. Defaults to just
32
+ * `on_interrupt` (the name LangGraph's AG-UI adapter emits).
33
+ */
34
+ interruptEventNames?: ReadonlySet<string>;
35
+ }): RunRenderer;
36
+ //# sourceMappingURL=event-renderer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"event-renderer.d.ts","sourceRoot":"","sources":["../src/event-renderer.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,WAAW,EAGZ,MAAM,sBAAsB,CAAC;AAwB9B;;;GAGG;AACH,MAAM,WAAW,WAAW;IAC1B,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5B,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG;QAAE,OAAO,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC;QACnD,EAAE,EAAE,MAAM,CAAC;QACX,IAAI,CAAC,CAAC,EAAE,MAAM,GAAG;YAAE,OAAO,EAAE,MAAM,CAAA;SAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;KACzD,CAAC,CAAC;CACJ;AAED;;;;;;;;;GASG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE;IACtC,OAAO,EAAE,WAAW,CAAC;IACrB;;;;OAIG;IACH,mBAAmB,CAAC,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;CAC3C,GAAG,WAAW,CAqQd"}
@@ -0,0 +1,270 @@
1
+ import { ChunkedMessageStream } from "./chunked-message-stream.js";
2
+ import { discordMarkdown } from "./markdown.js";
3
+ import { autoCloseOpenMarkdown } from "./auto-close-streaming.js";
4
+ /**
5
+ * The display-transform applied to every streaming chunk before it hits
6
+ * `message.edit`. Composed of two pure functions:
7
+ *
8
+ * 1. `autoCloseOpenMarkdown` — closes dangling fenced code, inline
9
+ * backticks, bold/italic/strike so a mid-stream buffer renders as
10
+ * valid Discord markdown (instead of leaking code-styling through the
11
+ * rest of the message). When the agent eventually emits the real
12
+ * close marker the buffer becomes balanced and this step adds
13
+ * nothing, so the committed message is never double-closed.
14
+ *
15
+ * 2. `discordMarkdown` — translates the (now-balanced) markdown for
16
+ * Discord (GFM table → fenced block; everything else is identity).
17
+ */
18
+ const displayTransform = (s) => discordMarkdown(autoCloseOpenMarkdown(s));
19
+ const INTERRUPTED_SUFFIX = "\n_(interrupted)_";
20
+ /**
21
+ * Construct a {@link RunRenderer} for a single agent run in Discord.
22
+ *
23
+ * The `subscriber` is passed to `runAgent`. After the run resolves, the
24
+ * run-loop reads `getCapturedToolCalls()` (filtering by which tools are
25
+ * registered) and `getPendingInterrupt()`. `markInterrupted()` is called
26
+ * when a new turn arrives for the same conversation while this run is
27
+ * still streaming — it appends a `_(interrupted)_` marker to any partial
28
+ * reply so the user sees a clear visual cue that the bot stopped.
29
+ */
30
+ export function createRunRenderer(args) {
31
+ const { channel } = args;
32
+ const interruptEventNames = args.interruptEventNames ?? new Set(["on_interrupt"]);
33
+ // ── Typing heartbeat ───────────────────────────────────────────────────
34
+ // Discord's typing indicator auto-expires after ~10 s. A single
35
+ // sendTyping() on run start leaves dead air whenever the agent spends
36
+ // longer than that reasoning or waiting on a slow MCP/tool call before
37
+ // its first token — the indicator vanishes and the bot looks crashed.
38
+ // So we refresh typing on an interval for the lifetime of the run and
39
+ // stop on the first terminal signal (RUN_FINISHED / RUN_ERROR / abort).
40
+ // Best-effort: a failing sendTyping must never crash the run. A refresh
41
+ // cap bounds the worst case if no terminal event ever arrives.
42
+ const TYPING_REFRESH_MS = 8000;
43
+ const TYPING_MAX_REFRESHES = 40; // ~5.3 min safety cap against a leaked timer
44
+ let typingTimer;
45
+ let typingRefreshes = 0;
46
+ const sendTypingSafe = () => {
47
+ void Promise.resolve(channel.sendTyping()).catch(() => {
48
+ /* best-effort */
49
+ });
50
+ };
51
+ const startTyping = () => {
52
+ if (typingTimer)
53
+ return;
54
+ typingRefreshes = 0;
55
+ sendTypingSafe();
56
+ typingTimer = setInterval(() => {
57
+ if (++typingRefreshes >= TYPING_MAX_REFRESHES) {
58
+ stopTyping();
59
+ return;
60
+ }
61
+ sendTypingSafe();
62
+ }, TYPING_REFRESH_MS);
63
+ // Don't keep the process alive solely for the typing heartbeat.
64
+ typingTimer.unref?.();
65
+ };
66
+ const stopTyping = () => {
67
+ if (typingTimer) {
68
+ clearInterval(typingTimer);
69
+ typingTimer = undefined;
70
+ }
71
+ };
72
+ /** Per-AG-UI-message accumulated text (we accumulate deltas locally). */
73
+ const buffers = new Map();
74
+ /** Per-AG-UI-message ChunkedMessageStream. Lazily created on first content. */
75
+ const streams = new Map();
76
+ /**
77
+ * One handle per posted Discord message, keyed by the id returned from
78
+ * `channel.send`. A long agent reply (>2000 chars) is split by
79
+ * {@link ChunkedMessageStream} into several Discord messages, and
80
+ * `updateAt(id, …)` must edit the message that owns `id` — NOT a single
81
+ * per-AG-UI-message handle, which every new chunk would overwrite,
82
+ * routing all edits to the last-posted message. Mirrors the `handles`
83
+ * Map in adapter.ts `stream()`. Shared across every stream in this run
84
+ * (Discord ids are globally unique, so there's no collision risk).
85
+ */
86
+ const handles = new Map();
87
+ /**
88
+ * Once a stream has been "finalised" (either via TEXT_MESSAGE_END or via
89
+ * markInterrupted), we drop it. Late-arriving events for the same
90
+ * messageId are ignored — they'd otherwise reopen a closed stream and
91
+ * post a fresh placeholder *after* the user already saw the message
92
+ * was over.
93
+ */
94
+ const finalised = new Set();
95
+ /**
96
+ * Set when the caller intentionally aborted the run (i.e. a new turn
97
+ * arrived for the same conversation). Suppresses further AG-UI event
98
+ * processing — the `_(interrupted)_` marker conveys the state visually.
99
+ */
100
+ let aborted = false;
101
+ /** Tool calls observed in this run, in event order. */
102
+ const capturedToolCalls = [];
103
+ /**
104
+ * Interrupt observed via a matching `onCustomEvent`. The React
105
+ * `useInterrupt` pattern buffers locally and commits on run-finalize,
106
+ * which we mirror via this single-slot variable — the run-loop reads it
107
+ * after `runAgent` resolves.
108
+ */
109
+ let pendingInterrupt;
110
+ const ensureStream = (messageId) => {
111
+ if (finalised.has(messageId))
112
+ return undefined;
113
+ let s = streams.get(messageId);
114
+ if (!s) {
115
+ s = new ChunkedMessageStream({
116
+ postPlaceholder: async (text) => {
117
+ const m = await channel.send(text);
118
+ handles.set(m.id, m);
119
+ return m.id;
120
+ },
121
+ updateAt: async (id, text) => {
122
+ await handles.get(id)?.edit(text);
123
+ },
124
+ transform: displayTransform,
125
+ });
126
+ streams.set(messageId, s);
127
+ }
128
+ return s;
129
+ };
130
+ // ── Tool-call dedup (verbatim from bot-slack/src/event-renderer.ts) ──
131
+ // Keep a single entry per toolCallId, overwriting on ARGS and END so
132
+ // the captured entry always holds the latest parsed shape.
133
+ const captureToolCall = (toolCallId, toolCallName, toolCallArgs) => {
134
+ const existing = capturedToolCalls.find((c) => c.toolCallId === toolCallId);
135
+ if (existing) {
136
+ existing.toolCallName = toolCallName;
137
+ existing.toolCallArgs = toolCallArgs;
138
+ }
139
+ else {
140
+ capturedToolCalls.push({ toolCallId, toolCallName, toolCallArgs });
141
+ }
142
+ };
143
+ const subscriber = {
144
+ // ── 0. Run lifecycle: typing heartbeat ────────────────────────────
145
+ // Start the typing heartbeat so the indicator stays alive through long
146
+ // tool/MCP calls (see startTyping above), not just the first ~10 s.
147
+ onRunStartedEvent() {
148
+ if (aborted)
149
+ return;
150
+ startTyping();
151
+ },
152
+ // Stop the heartbeat once the run completes so the indicator doesn't
153
+ // linger past the final message.
154
+ onRunFinishedEvent() {
155
+ stopTyping();
156
+ },
157
+ // ── 1. Text streaming ──────────────────────────────────────────────
158
+ onTextMessageStartEvent({ event }) {
159
+ if (aborted)
160
+ return;
161
+ buffers.set(event.messageId, "");
162
+ },
163
+ onTextMessageContentEvent({ event }) {
164
+ if (aborted)
165
+ return;
166
+ const next = (buffers.get(event.messageId) ?? "") + (event.delta ?? "");
167
+ buffers.set(event.messageId, next);
168
+ ensureStream(event.messageId)?.append(next);
169
+ },
170
+ async onTextMessageEndEvent({ event }) {
171
+ if (aborted)
172
+ return;
173
+ const stream = streams.get(event.messageId);
174
+ if (stream) {
175
+ await stream.finish();
176
+ streams.delete(event.messageId);
177
+ }
178
+ buffers.delete(event.messageId);
179
+ finalised.add(event.messageId);
180
+ },
181
+ // ── 2. Tool-call capture ──────────────────────────────────────────
182
+ onToolCallArgsEvent({ event, toolCallName, partialToolCallArgs }) {
183
+ if (aborted)
184
+ return;
185
+ // Accumulate partial args so the captured entry tracks the latest
186
+ // parsed shape as args stream in. We keep a single entry per
187
+ // toolCallId, overwriting `toolCallArgs`.
188
+ captureToolCall(event.toolCallId, toolCallName, (partialToolCallArgs ?? {}));
189
+ },
190
+ async onToolCallEndEvent({ event, toolCallName, toolCallArgs }) {
191
+ if (aborted)
192
+ return;
193
+ // Capture EVERY tool call — the run-loop filters by which tools are
194
+ // registered. A tool with no `args` events still lands here.
195
+ captureToolCall(event.toolCallId, toolCallName, (toolCallArgs ?? {}));
196
+ },
197
+ // ── 3. Interrupts (LangGraph `interrupt()` → AG-UI custom event) ─
198
+ // Verbatim from bot-slack/src/event-renderer.ts.
199
+ onCustomEvent({ event }) {
200
+ if (aborted)
201
+ return;
202
+ const e = event;
203
+ if (!e.name || !interruptEventNames.has(e.name))
204
+ return;
205
+ // LangGraph's AG-UI adapter ships the interrupt value as a JSON
206
+ // string in some shapes (and as an object in others). Normalize
207
+ // here so downstream schema validation always sees the parsed shape.
208
+ let value = e.value;
209
+ if (typeof value === "string") {
210
+ try {
211
+ value = JSON.parse(value);
212
+ }
213
+ catch {
214
+ // Leave it as a string — the handler's schema will reject it
215
+ // explicitly with a clearer error.
216
+ }
217
+ }
218
+ pendingInterrupt = { eventName: e.name, value };
219
+ },
220
+ // ── 4. Errors ─────────────────────────────────────────────────────
221
+ // When an agent run fails (RUN_ERROR), surface a visible warning in
222
+ // the channel so the user isn't left staring at an expired typing
223
+ // indicator with no feedback. Suppressed when the abort was
224
+ // self-initiated — the `_(interrupted)_` marker is the user-visible
225
+ // signal in that case. Best-effort, like the typing call: a failure
226
+ // here must never crash the run.
227
+ async onRunErrorEvent({ event }) {
228
+ stopTyping();
229
+ if (aborted)
230
+ return;
231
+ try {
232
+ await channel.send(`⚠️ Agent error: ${event.message ?? "unknown error"}`);
233
+ }
234
+ catch (err) {
235
+ console.error("[discord-renderer] error notice failed:", err);
236
+ }
237
+ },
238
+ };
239
+ return {
240
+ subscriber,
241
+ getCapturedToolCalls: () => capturedToolCalls,
242
+ getPendingInterrupt: () => pendingInterrupt,
243
+ clearPendingInterrupt: () => {
244
+ pendingInterrupt = undefined;
245
+ },
246
+ async markInterrupted() {
247
+ // Idempotent. Mark BEFORE any await so subsequent subscriber
248
+ // callbacks see the flag and bail.
249
+ if (aborted)
250
+ return;
251
+ aborted = true;
252
+ stopTyping();
253
+ // For each in-flight text-message stream, append the interrupted
254
+ // marker and drain. Streams that have no content yet are silently
255
+ // dropped (the bot never posted anything for them).
256
+ const tasks = [];
257
+ for (const [id, stream] of Array.from(streams.entries())) {
258
+ const buf = buffers.get(id) ?? "";
259
+ if (buf.length > 0) {
260
+ stream.append(buf + INTERRUPTED_SUFFIX);
261
+ tasks.push(stream.finish());
262
+ }
263
+ streams.delete(id);
264
+ finalised.add(id);
265
+ }
266
+ buffers.clear();
267
+ await Promise.all(tasks);
268
+ },
269
+ };
270
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=event-renderer.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"event-renderer.test.d.ts","sourceRoot":"","sources":["../src/event-renderer.test.ts"],"names":[],"mappings":""}