@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.
- package/LICENSE +21 -0
- package/README.md +308 -0
- package/dist/__tests__/ephemeral.test.d.ts +2 -0
- package/dist/__tests__/ephemeral.test.d.ts.map +1 -0
- package/dist/__tests__/ephemeral.test.js +26 -0
- package/dist/__tests__/intents.test.d.ts +2 -0
- package/dist/__tests__/intents.test.d.ts.map +1 -0
- package/dist/__tests__/intents.test.js +8 -0
- package/dist/__tests__/modal-submit.test.d.ts +2 -0
- package/dist/__tests__/modal-submit.test.d.ts.map +1 -0
- package/dist/__tests__/modal-submit.test.js +26 -0
- package/dist/__tests__/reactions.test.d.ts +2 -0
- package/dist/__tests__/reactions.test.d.ts.map +1 -0
- package/dist/__tests__/reactions.test.js +87 -0
- package/dist/adapter.d.ts +118 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +567 -0
- package/dist/adapter.test.d.ts +2 -0
- package/dist/adapter.test.d.ts.map +1 -0
- package/dist/adapter.test.js +343 -0
- package/dist/auto-close-streaming.d.ts +61 -0
- package/dist/auto-close-streaming.d.ts.map +1 -0
- package/dist/auto-close-streaming.js +276 -0
- package/dist/auto-close-streaming.test.d.ts +2 -0
- package/dist/auto-close-streaming.test.d.ts.map +1 -0
- package/dist/auto-close-streaming.test.js +149 -0
- package/dist/built-in-context.d.ts +20 -0
- package/dist/built-in-context.d.ts.map +1 -0
- package/dist/built-in-context.js +30 -0
- package/dist/built-in-context.test.d.ts +2 -0
- package/dist/built-in-context.test.d.ts.map +1 -0
- package/dist/built-in-context.test.js +21 -0
- package/dist/built-in-tools.d.ts +4 -0
- package/dist/built-in-tools.d.ts.map +1 -0
- package/dist/built-in-tools.js +27 -0
- package/dist/built-in-tools.test.d.ts +2 -0
- package/dist/built-in-tools.test.d.ts.map +1 -0
- package/dist/built-in-tools.test.js +30 -0
- package/dist/chunked-message-stream.d.ts +94 -0
- package/dist/chunked-message-stream.d.ts.map +1 -0
- package/dist/chunked-message-stream.js +203 -0
- package/dist/chunked-message-stream.test.d.ts +2 -0
- package/dist/chunked-message-stream.test.d.ts.map +1 -0
- package/dist/chunked-message-stream.test.js +384 -0
- package/dist/commands.d.ts +29 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/commands.js +193 -0
- package/dist/commands.test.d.ts +2 -0
- package/dist/commands.test.d.ts.map +1 -0
- package/dist/commands.test.js +281 -0
- package/dist/conversation-store.d.ts +45 -0
- package/dist/conversation-store.d.ts.map +1 -0
- package/dist/conversation-store.js +78 -0
- package/dist/conversation-store.test.d.ts +2 -0
- package/dist/conversation-store.test.d.ts.map +1 -0
- package/dist/conversation-store.test.js +106 -0
- package/dist/discord-listener.d.ts +88 -0
- package/dist/discord-listener.d.ts.map +1 -0
- package/dist/discord-listener.js +118 -0
- package/dist/discord-listener.test.d.ts +2 -0
- package/dist/discord-listener.test.d.ts.map +1 -0
- package/dist/discord-listener.test.js +361 -0
- package/dist/download-files.d.ts +94 -0
- package/dist/download-files.d.ts.map +1 -0
- package/dist/download-files.js +145 -0
- package/dist/download-files.test.d.ts +2 -0
- package/dist/download-files.test.d.ts.map +1 -0
- package/dist/download-files.test.js +181 -0
- package/dist/event-renderer.d.ts +36 -0
- package/dist/event-renderer.d.ts.map +1 -0
- package/dist/event-renderer.js +270 -0
- package/dist/event-renderer.test.d.ts +2 -0
- package/dist/event-renderer.test.d.ts.map +1 -0
- package/dist/event-renderer.test.js +160 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/interaction.d.ts +11 -0
- package/dist/interaction.d.ts.map +1 -0
- package/dist/interaction.js +138 -0
- package/dist/interaction.test.d.ts +2 -0
- package/dist/interaction.test.d.ts.map +1 -0
- package/dist/interaction.test.js +120 -0
- package/dist/markdown.d.ts +7 -0
- package/dist/markdown.d.ts.map +1 -0
- package/dist/markdown.js +66 -0
- package/dist/markdown.test.d.ts +2 -0
- package/dist/markdown.test.d.ts.map +1 -0
- package/dist/markdown.test.js +47 -0
- package/dist/message-stream.d.ts +45 -0
- package/dist/message-stream.d.ts.map +1 -0
- package/dist/message-stream.js +76 -0
- package/dist/message-stream.test.d.ts +2 -0
- package/dist/message-stream.test.d.ts.map +1 -0
- package/dist/message-stream.test.js +162 -0
- package/dist/pending-interactions.d.ts +26 -0
- package/dist/pending-interactions.d.ts.map +1 -0
- package/dist/pending-interactions.js +50 -0
- package/dist/pending-interactions.test.d.ts +2 -0
- package/dist/pending-interactions.test.d.ts.map +1 -0
- package/dist/pending-interactions.test.js +73 -0
- package/dist/render/budget.d.ts +28 -0
- package/dist/render/budget.d.ts.map +1 -0
- package/dist/render/budget.js +49 -0
- package/dist/render/budget.test.d.ts +2 -0
- package/dist/render/budget.test.d.ts.map +1 -0
- package/dist/render/budget.test.js +59 -0
- package/dist/render/components-v2.d.ts +16 -0
- package/dist/render/components-v2.d.ts.map +1 -0
- package/dist/render/components-v2.js +396 -0
- package/dist/render/components-v2.test.d.ts +2 -0
- package/dist/render/components-v2.test.d.ts.map +1 -0
- package/dist/render/components-v2.test.js +395 -0
- package/dist/render/modal.d.ts +12 -0
- package/dist/render/modal.d.ts.map +1 -0
- package/dist/render/modal.js +44 -0
- package/dist/types.d.ts +16 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +4 -0
- package/dist/types.test.d.ts +2 -0
- package/dist/types.test.d.ts.map +1 -0
- package/dist/types.test.js +12 -0
- 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 @@
|
|
|
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 @@
|
|
|
1
|
+
{"version":3,"file":"event-renderer.test.d.ts","sourceRoot":"","sources":["../src/event-renderer.test.ts"],"names":[],"mappings":""}
|