@copilotkit/channels-teams 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +141 -0
- package/dist/adapter.d.ts +133 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +539 -0
- package/dist/adapter.test.d.ts +2 -0
- package/dist/adapter.test.d.ts.map +1 -0
- package/dist/adapter.test.js +202 -0
- package/dist/conversation-store.d.ts +36 -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 +72 -0
- package/dist/download-files.d.ts +62 -0
- package/dist/download-files.d.ts.map +1 -0
- package/dist/download-files.js +190 -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 +96 -0
- package/dist/event-renderer.d.ts +23 -0
- package/dist/event-renderer.d.ts.map +1 -0
- package/dist/event-renderer.js +135 -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 +95 -0
- package/dist/graph-files.d.ts +50 -0
- package/dist/graph-files.d.ts.map +1 -0
- package/dist/graph-files.js +126 -0
- package/dist/graph-files.test.d.ts +2 -0
- package/dist/graph-files.test.d.ts.map +1 -0
- package/dist/graph-files.test.js +114 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/interaction.d.ts +36 -0
- package/dist/interaction.d.ts.map +1 -0
- package/dist/interaction.js +35 -0
- package/dist/interaction.test.d.ts +2 -0
- package/dist/interaction.test.d.ts.map +1 -0
- package/dist/interaction.test.js +43 -0
- package/dist/listener.d.ts +26 -0
- package/dist/listener.d.ts.map +1 -0
- package/dist/listener.js +55 -0
- package/dist/listener.test.d.ts +2 -0
- package/dist/listener.test.d.ts.map +1 -0
- package/dist/listener.test.js +43 -0
- package/dist/message-stream.d.ts +52 -0
- package/dist/message-stream.d.ts.map +1 -0
- package/dist/message-stream.js +78 -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 +44 -0
- package/dist/render/adaptive-card.d.ts +43 -0
- package/dist/render/adaptive-card.d.ts.map +1 -0
- package/dist/render/adaptive-card.js +401 -0
- package/dist/render/adaptive-card.test.d.ts +2 -0
- package/dist/render/adaptive-card.test.d.ts.map +1 -0
- package/dist/render/adaptive-card.test.js +240 -0
- package/dist/render/auto-close.d.ts +27 -0
- package/dist/render/auto-close.d.ts.map +1 -0
- package/dist/render/auto-close.js +153 -0
- package/dist/render/auto-close.test.d.ts +2 -0
- package/dist/render/auto-close.test.d.ts.map +1 -0
- package/dist/render/auto-close.test.js +46 -0
- package/dist/render/budget.d.ts +38 -0
- package/dist/render/budget.d.ts.map +1 -0
- package/dist/render/budget.js +44 -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 +25 -0
- package/dist/render/markdown.d.ts +13 -0
- package/dist/render/markdown.d.ts.map +1 -0
- package/dist/render/markdown.js +121 -0
- package/dist/render/markdown.test.d.ts +2 -0
- package/dist/render/markdown.test.d.ts.map +1 -0
- package/dist/render/markdown.test.js +58 -0
- package/dist/sanitizing-http-agent.d.ts +35 -0
- package/dist/sanitizing-http-agent.d.ts.map +1 -0
- package/dist/sanitizing-http-agent.js +58 -0
- package/dist/types.d.ts +53 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/package.json +67 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
2
|
+
import { buildFileContentParts } from "./download-files.js";
|
|
3
|
+
const FILE_DOWNLOAD_INFO = "application/vnd.microsoft.teams.file.download.info";
|
|
4
|
+
function mockFetch(body, init = {}) {
|
|
5
|
+
const bytes = typeof body === "string" ? new TextEncoder().encode(body) : body;
|
|
6
|
+
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
|
|
7
|
+
ok: init.ok ?? true,
|
|
8
|
+
status: init.status ?? 200,
|
|
9
|
+
arrayBuffer: async () => bytes.buffer,
|
|
10
|
+
}));
|
|
11
|
+
}
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
vi.unstubAllGlobals();
|
|
14
|
+
});
|
|
15
|
+
describe("buildFileContentParts (Teams)", () => {
|
|
16
|
+
it("downloads a CSV file.download.info attachment as a decoded text part", async () => {
|
|
17
|
+
mockFetch("a,b\n1,2\n");
|
|
18
|
+
const att = {
|
|
19
|
+
contentType: FILE_DOWNLOAD_INFO,
|
|
20
|
+
name: "data.csv",
|
|
21
|
+
content: {
|
|
22
|
+
downloadUrl: "https://files.example/data.csv",
|
|
23
|
+
fileType: "csv",
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
const { parts, notes } = await buildFileContentParts([att]);
|
|
27
|
+
expect(notes).toEqual([]);
|
|
28
|
+
expect(parts).toHaveLength(1);
|
|
29
|
+
expect(parts[0]).toMatchObject({ type: "text" });
|
|
30
|
+
const text = parts[0].text;
|
|
31
|
+
expect(text).toContain('Attached file "data.csv" (text/csv)');
|
|
32
|
+
expect(text).toContain("a,b\n1,2");
|
|
33
|
+
expect(fetch).toHaveBeenCalledWith("https://files.example/data.csv");
|
|
34
|
+
});
|
|
35
|
+
it("decodes a base64 data: URI image into an image part without fetching", async () => {
|
|
36
|
+
const fetchSpy = vi.fn();
|
|
37
|
+
vi.stubGlobal("fetch", fetchSpy);
|
|
38
|
+
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47]).toString("base64");
|
|
39
|
+
const att = {
|
|
40
|
+
contentType: "image/png",
|
|
41
|
+
name: "chart.png",
|
|
42
|
+
contentUrl: `data:image/png;base64,${png}`,
|
|
43
|
+
};
|
|
44
|
+
const { parts } = await buildFileContentParts([att]);
|
|
45
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
46
|
+
expect(parts[0]).toMatchObject({
|
|
47
|
+
type: "image",
|
|
48
|
+
source: { type: "data", value: png, mimeType: "image/png" },
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
it("fetches an https image attachment and passes it through as binary", async () => {
|
|
52
|
+
mockFetch(new Uint8Array([1, 2, 3, 4]));
|
|
53
|
+
const att = {
|
|
54
|
+
contentType: "image/jpeg",
|
|
55
|
+
name: "photo.jpg",
|
|
56
|
+
contentUrl: "https://smba.example/v3/attachments/123",
|
|
57
|
+
};
|
|
58
|
+
const { parts } = await buildFileContentParts([att]);
|
|
59
|
+
expect(parts[0]).toMatchObject({
|
|
60
|
+
type: "image",
|
|
61
|
+
source: { type: "data", mimeType: "image/jpeg" },
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
it("notes a download failure instead of throwing", async () => {
|
|
65
|
+
mockFetch("", { ok: false, status: 404 });
|
|
66
|
+
const att = {
|
|
67
|
+
contentType: FILE_DOWNLOAD_INFO,
|
|
68
|
+
name: "data.csv",
|
|
69
|
+
content: { downloadUrl: "https://files.example/missing.csv" },
|
|
70
|
+
};
|
|
71
|
+
const { parts, notes } = await buildFileContentParts([att]);
|
|
72
|
+
expect(parts).toEqual([]);
|
|
73
|
+
expect(notes[0]).toContain("download failed (HTTP 404)");
|
|
74
|
+
});
|
|
75
|
+
it("skips an Adaptive Card attachment (no downloadable source)", async () => {
|
|
76
|
+
const att = {
|
|
77
|
+
contentType: "application/vnd.microsoft.card.adaptive",
|
|
78
|
+
content: { type: "AdaptiveCard" },
|
|
79
|
+
};
|
|
80
|
+
const { parts, notes } = await buildFileContentParts([att]);
|
|
81
|
+
expect(parts).toEqual([]);
|
|
82
|
+
expect(notes).toEqual([]);
|
|
83
|
+
});
|
|
84
|
+
it("caps the number of files processed", async () => {
|
|
85
|
+
mockFetch("x");
|
|
86
|
+
const make = (i) => ({
|
|
87
|
+
contentType: FILE_DOWNLOAD_INFO,
|
|
88
|
+
name: `f${i}.txt`,
|
|
89
|
+
content: { downloadUrl: `https://files.example/f${i}.txt` },
|
|
90
|
+
});
|
|
91
|
+
const atts = Array.from({ length: 7 }, (_, i) => make(i));
|
|
92
|
+
const { parts, notes } = await buildFileContentParts(atts, { maxFiles: 3 });
|
|
93
|
+
expect(parts).toHaveLength(3);
|
|
94
|
+
expect(notes.some((n) => n.includes("only the first 3 of 7"))).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { RunRenderer } from "@copilotkit/channels";
|
|
2
|
+
/**
|
|
3
|
+
* Build a {@link RunRenderer} for a single agent run in Teams.
|
|
4
|
+
*
|
|
5
|
+
* Each AG-UI text message is **streamed by message edit**: on the first content
|
|
6
|
+
* delta we post a Teams message (after a typing indicator), then `updateActivity`
|
|
7
|
+
* it as the text grows (throttled), and finalize on message-end. This is Teams'
|
|
8
|
+
* baseline streaming model (see {@link TeamsMessageStream}). Tool calls and
|
|
9
|
+
* interrupts are captured for the run-loop to read after `runAgent` resolves,
|
|
10
|
+
* exactly as the Slack adapter does.
|
|
11
|
+
*/
|
|
12
|
+
export declare function createRunRenderer(args: {
|
|
13
|
+
/** First send for a streamed message. Returns the posted activity id. */
|
|
14
|
+
post: (text: string) => Promise<string>;
|
|
15
|
+
/** Edit a previously-posted streamed message. */
|
|
16
|
+
update: (id: string, text: string) => Promise<void>;
|
|
17
|
+
/** Optional typing indicator, fired once before a message's first post. */
|
|
18
|
+
typing?: () => Promise<void>;
|
|
19
|
+
interruptEventNames?: ReadonlySet<string>;
|
|
20
|
+
/** Persist the agent's reply text to the conversation transcript. */
|
|
21
|
+
recordAssistant?: (text: string) => void;
|
|
22
|
+
}): RunRenderer;
|
|
23
|
+
//# 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;AAM9B;;;;;;;;;GASG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE;IACtC,yEAAyE;IACzE,IAAI,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IACxC,iDAAiD;IACjD,MAAM,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACpD,2EAA2E;IAC3E,MAAM,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7B,mBAAmB,CAAC,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IAC1C,qEAAqE;IACrE,eAAe,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CAC1C,GAAG,WAAW,CAkId"}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { TeamsMessageStream } from "./message-stream.js";
|
|
2
|
+
import { autoCloseOpenMarkdown } from "./render/auto-close.js";
|
|
3
|
+
const INTERRUPTED_SUFFIX = "\n\n_(interrupted)_";
|
|
4
|
+
/**
|
|
5
|
+
* Build a {@link RunRenderer} for a single agent run in Teams.
|
|
6
|
+
*
|
|
7
|
+
* Each AG-UI text message is **streamed by message edit**: on the first content
|
|
8
|
+
* delta we post a Teams message (after a typing indicator), then `updateActivity`
|
|
9
|
+
* it as the text grows (throttled), and finalize on message-end. This is Teams'
|
|
10
|
+
* baseline streaming model (see {@link TeamsMessageStream}). Tool calls and
|
|
11
|
+
* interrupts are captured for the run-loop to read after `runAgent` resolves,
|
|
12
|
+
* exactly as the Slack adapter does.
|
|
13
|
+
*/
|
|
14
|
+
export function createRunRenderer(args) {
|
|
15
|
+
const interruptEventNames = args.interruptEventNames ?? new Set(["on_interrupt"]);
|
|
16
|
+
/** Per-AG-UI-message accumulated text + its streamed-by-edit message. */
|
|
17
|
+
const buffers = new Map();
|
|
18
|
+
const streams = new Map();
|
|
19
|
+
const capturedToolCalls = [];
|
|
20
|
+
let pendingInterrupt;
|
|
21
|
+
let aborted = false;
|
|
22
|
+
const streamFor = (messageId) => {
|
|
23
|
+
let s = streams.get(messageId);
|
|
24
|
+
if (!s) {
|
|
25
|
+
s = new TeamsMessageStream({
|
|
26
|
+
post: args.post,
|
|
27
|
+
update: args.update,
|
|
28
|
+
typing: args.typing,
|
|
29
|
+
});
|
|
30
|
+
streams.set(messageId, s);
|
|
31
|
+
}
|
|
32
|
+
return s;
|
|
33
|
+
};
|
|
34
|
+
const captureToolCall = (toolCallId, toolCallName, toolCallArgs) => {
|
|
35
|
+
const existing = capturedToolCalls.find((c) => c.toolCallId === toolCallId);
|
|
36
|
+
if (existing) {
|
|
37
|
+
existing.toolCallName = toolCallName;
|
|
38
|
+
existing.toolCallArgs = toolCallArgs;
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
capturedToolCalls.push({ toolCallId, toolCallName, toolCallArgs });
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
const subscriber = {
|
|
45
|
+
onTextMessageStartEvent({ event }) {
|
|
46
|
+
if (aborted)
|
|
47
|
+
return;
|
|
48
|
+
buffers.set(event.messageId, "");
|
|
49
|
+
streamFor(event.messageId);
|
|
50
|
+
},
|
|
51
|
+
onTextMessageContentEvent({ event }) {
|
|
52
|
+
if (aborted)
|
|
53
|
+
return;
|
|
54
|
+
const next = (buffers.get(event.messageId) ?? "") + (event.delta ?? "");
|
|
55
|
+
buffers.set(event.messageId, next);
|
|
56
|
+
// Mid-stream the buffer is usually unbalanced markdown (an open `**`,
|
|
57
|
+
// code fence, etc.); balance it for display so the edited message never
|
|
58
|
+
// renders broken. The finalized message uses the raw text (below).
|
|
59
|
+
streamFor(event.messageId).append(autoCloseOpenMarkdown(next));
|
|
60
|
+
},
|
|
61
|
+
async onTextMessageEndEvent({ event }) {
|
|
62
|
+
if (aborted)
|
|
63
|
+
return;
|
|
64
|
+
const text = buffers.get(event.messageId) ?? "";
|
|
65
|
+
buffers.delete(event.messageId);
|
|
66
|
+
// Commit the agent's exact final text (now balanced on its own) so the
|
|
67
|
+
// settled message carries no synthetic closers.
|
|
68
|
+
const stream = streamFor(event.messageId);
|
|
69
|
+
stream.append(text);
|
|
70
|
+
await stream.finish();
|
|
71
|
+
streams.delete(event.messageId);
|
|
72
|
+
const trimmed = text.trim();
|
|
73
|
+
if (trimmed)
|
|
74
|
+
args.recordAssistant?.(trimmed);
|
|
75
|
+
},
|
|
76
|
+
onToolCallArgsEvent({ event, toolCallName, partialToolCallArgs }) {
|
|
77
|
+
if (aborted)
|
|
78
|
+
return;
|
|
79
|
+
captureToolCall(event.toolCallId, toolCallName, (partialToolCallArgs ?? {}));
|
|
80
|
+
},
|
|
81
|
+
onToolCallEndEvent({ event, toolCallName, toolCallArgs }) {
|
|
82
|
+
if (aborted)
|
|
83
|
+
return;
|
|
84
|
+
captureToolCall(event.toolCallId, toolCallName, (toolCallArgs ?? {}));
|
|
85
|
+
},
|
|
86
|
+
onCustomEvent({ event }) {
|
|
87
|
+
if (aborted)
|
|
88
|
+
return;
|
|
89
|
+
const e = event;
|
|
90
|
+
if (!e.name || !interruptEventNames.has(e.name))
|
|
91
|
+
return;
|
|
92
|
+
let value = e.value;
|
|
93
|
+
if (typeof value === "string") {
|
|
94
|
+
try {
|
|
95
|
+
value = JSON.parse(value);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// Leave as a string. The handler's schema rejects it explicitly.
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
pendingInterrupt = { eventName: e.name, value };
|
|
102
|
+
},
|
|
103
|
+
async onRunErrorEvent({ event }) {
|
|
104
|
+
if (aborted)
|
|
105
|
+
return;
|
|
106
|
+
await args.post(`⚠️ Agent error: ${event.message ?? "unknown error"}`);
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
return {
|
|
110
|
+
subscriber,
|
|
111
|
+
getCapturedToolCalls: () => capturedToolCalls,
|
|
112
|
+
getPendingInterrupt: () => pendingInterrupt,
|
|
113
|
+
clearPendingInterrupt: () => {
|
|
114
|
+
pendingInterrupt = undefined;
|
|
115
|
+
},
|
|
116
|
+
async markInterrupted() {
|
|
117
|
+
if (aborted)
|
|
118
|
+
return;
|
|
119
|
+
aborted = true;
|
|
120
|
+
// Flush any partial reply with a marker so the user sees the run stopped.
|
|
121
|
+
const tasks = [];
|
|
122
|
+
for (const [id, buf] of Array.from(buffers.entries())) {
|
|
123
|
+
const s = streams.get(id);
|
|
124
|
+
if (s && buf.length > 0) {
|
|
125
|
+
// Balance the partial buffer so the interrupted marker reads cleanly.
|
|
126
|
+
s.append(autoCloseOpenMarkdown(buf) + INTERRUPTED_SUFFIX);
|
|
127
|
+
tasks.push(s.finish());
|
|
128
|
+
}
|
|
129
|
+
buffers.delete(id);
|
|
130
|
+
streams.delete(id);
|
|
131
|
+
}
|
|
132
|
+
await Promise.all(tasks);
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"event-renderer.test.d.ts","sourceRoot":"","sources":["../src/event-renderer.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { createRunRenderer } from "./event-renderer.js";
|
|
3
|
+
/**
|
|
4
|
+
* Stub the four callbacks the renderer drives (`post` / `update` / `typing` /
|
|
5
|
+
* `recordAssistant`) and record every send in order. `post` returns a stable
|
|
6
|
+
* activity id so later edits address the same message.
|
|
7
|
+
*/
|
|
8
|
+
function makeSink() {
|
|
9
|
+
const posts = [];
|
|
10
|
+
const updates = [];
|
|
11
|
+
const recorded = [];
|
|
12
|
+
const typing = vi.fn(async () => { });
|
|
13
|
+
const post = vi.fn(async (text) => {
|
|
14
|
+
posts.push(text);
|
|
15
|
+
return "activity-1";
|
|
16
|
+
});
|
|
17
|
+
const update = vi.fn(async (id, text) => {
|
|
18
|
+
updates.push({ id, text });
|
|
19
|
+
});
|
|
20
|
+
const recordAssistant = vi.fn((text) => {
|
|
21
|
+
recorded.push(text);
|
|
22
|
+
});
|
|
23
|
+
return { posts, updates, recorded, typing, post, update, recordAssistant };
|
|
24
|
+
}
|
|
25
|
+
describe("createRunRenderer (Teams)", () => {
|
|
26
|
+
it("accumulates deltas into the final streamed message and records it", async () => {
|
|
27
|
+
// AG-UI delivers text one delta at a time; the renderer must accumulate
|
|
28
|
+
// them itself (not forward the lagging per-event buffer) so the settled
|
|
29
|
+
// Teams message reads "ECHO", not "E".
|
|
30
|
+
const sink = makeSink();
|
|
31
|
+
const { subscriber: sub } = createRunRenderer(sink);
|
|
32
|
+
const id = "msg-1";
|
|
33
|
+
await sub.onTextMessageStartEvent({
|
|
34
|
+
event: { messageId: id, role: "assistant" },
|
|
35
|
+
});
|
|
36
|
+
sub.onTextMessageContentEvent({
|
|
37
|
+
event: { messageId: id, delta: "E" },
|
|
38
|
+
});
|
|
39
|
+
sub.onTextMessageContentEvent({
|
|
40
|
+
event: { messageId: id, delta: "CHO" },
|
|
41
|
+
});
|
|
42
|
+
await sub.onTextMessageEndEvent({ event: { messageId: id } });
|
|
43
|
+
// Whatever the throttle split (single post, or post-then-edits), the last
|
|
44
|
+
// text Teams sees is the fully-accumulated reply.
|
|
45
|
+
const lastSent = sink.updates.at(-1)?.text ?? sink.posts.at(-1);
|
|
46
|
+
expect(lastSent).toBe("ECHO");
|
|
47
|
+
// A typing indicator fires once before the first post.
|
|
48
|
+
expect(sink.typing).toHaveBeenCalledTimes(1);
|
|
49
|
+
// The final text is committed to the conversation transcript.
|
|
50
|
+
expect(sink.recorded).toEqual(["ECHO"]);
|
|
51
|
+
});
|
|
52
|
+
it("captures tool calls for the run loop to read", async () => {
|
|
53
|
+
const sink = makeSink();
|
|
54
|
+
const renderer = createRunRenderer(sink);
|
|
55
|
+
renderer.subscriber.onToolCallEndEvent({
|
|
56
|
+
event: { toolCallId: "t1" },
|
|
57
|
+
toolCallName: "show_card",
|
|
58
|
+
toolCallArgs: { title: "Status" },
|
|
59
|
+
});
|
|
60
|
+
expect(renderer.getCapturedToolCalls()).toEqual([
|
|
61
|
+
{
|
|
62
|
+
toolCallId: "t1",
|
|
63
|
+
toolCallName: "show_card",
|
|
64
|
+
toolCallArgs: { title: "Status" },
|
|
65
|
+
},
|
|
66
|
+
]);
|
|
67
|
+
});
|
|
68
|
+
it("captures a custom interrupt event and JSON-parses its value", async () => {
|
|
69
|
+
const sink = makeSink();
|
|
70
|
+
const renderer = createRunRenderer(sink);
|
|
71
|
+
renderer.subscriber.onCustomEvent({
|
|
72
|
+
event: { name: "on_interrupt", value: '{"confirmed":true}' },
|
|
73
|
+
});
|
|
74
|
+
expect(renderer.getPendingInterrupt()).toEqual({
|
|
75
|
+
eventName: "on_interrupt",
|
|
76
|
+
value: { confirmed: true },
|
|
77
|
+
});
|
|
78
|
+
renderer.clearPendingInterrupt();
|
|
79
|
+
expect(renderer.getPendingInterrupt()).toBeUndefined();
|
|
80
|
+
});
|
|
81
|
+
it("ignores custom events whose name isn't an interrupt", async () => {
|
|
82
|
+
const sink = makeSink();
|
|
83
|
+
const renderer = createRunRenderer(sink);
|
|
84
|
+
renderer.subscriber.onCustomEvent({
|
|
85
|
+
event: { name: "telemetry", value: "{}" },
|
|
86
|
+
});
|
|
87
|
+
expect(renderer.getPendingInterrupt()).toBeUndefined();
|
|
88
|
+
});
|
|
89
|
+
it("posts a visible message on a run error", async () => {
|
|
90
|
+
const sink = makeSink();
|
|
91
|
+
const { subscriber: sub } = createRunRenderer(sink);
|
|
92
|
+
await sub.onRunErrorEvent({ event: { message: "boom" } });
|
|
93
|
+
expect(sink.posts).toContain("⚠️ Agent error: boom");
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Microsoft Graph file access for Teams CHANNEL messages.
|
|
3
|
+
*
|
|
4
|
+
* The Teams bot file API (the `file.download.info` attachment) only fires in a
|
|
5
|
+
* 1:1 personal chat. In a channel the bot's inbound activity carries no file at
|
|
6
|
+
* all — the upload lives in the team's SharePoint document library and is only
|
|
7
|
+
* referenced from the channel *message*, which the bot doesn't receive inline.
|
|
8
|
+
* So to chart "a CSV someone dropped in a channel" we go through Graph:
|
|
9
|
+
*
|
|
10
|
+
* 1. Acquire an app-only Graph token (client credentials — the same
|
|
11
|
+
* clientId/clientSecret/tenantId the adapter already uses for Teams).
|
|
12
|
+
* 2. Read the channel message to find its file attachments
|
|
13
|
+
* (`GET /teams/{team}/channels/{channel}/messages/{id}` — app-only via the
|
|
14
|
+
* RSC permission `ChannelMessage.Read.Group`, declared in the app
|
|
15
|
+
* manifest; no tenant admin consent or protected-API approval needed).
|
|
16
|
+
* 3. Download each referenced SharePoint file via the `/shares` endpoint
|
|
17
|
+
* (needs the `Files.Read.All` application permission + admin consent).
|
|
18
|
+
*
|
|
19
|
+
* Returns nothing (with a note) when Graph isn't configured or a step fails, so
|
|
20
|
+
* the bot degrades to asking the user to paste the data rather than crashing.
|
|
21
|
+
*/
|
|
22
|
+
import { type FileDeliveryConfig } from "./download-files.js";
|
|
23
|
+
import type { AgentContentPart } from "@copilotkit/channels-ui";
|
|
24
|
+
/** The Microsoft credentials needed for an app-only Graph token. */
|
|
25
|
+
export interface GraphCredentials {
|
|
26
|
+
clientId: string;
|
|
27
|
+
clientSecret: string;
|
|
28
|
+
tenantId: string;
|
|
29
|
+
}
|
|
30
|
+
/** Identifiers needed to read a channel message, pulled from the activity. */
|
|
31
|
+
export interface ChannelMessageRef {
|
|
32
|
+
/** The team's Entra (AAD) group id — `channelData.team.aadGroupId`. */
|
|
33
|
+
teamId: string;
|
|
34
|
+
/** The channel thread id — `channelData.teamsChannelId`. */
|
|
35
|
+
channelId: string;
|
|
36
|
+
/** The inbound message id (`activity.id`). */
|
|
37
|
+
messageId: string;
|
|
38
|
+
/** Root message id when this is a reply; equals `messageId` for a top-level post. */
|
|
39
|
+
rootId: string;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Resolve a channel message's uploaded files into AG-UI content parts via
|
|
43
|
+
* Graph. Returns `{ parts, notes }`; on any failure the parts are empty and a
|
|
44
|
+
* note explains why (so the caller can tell the user to paste the data).
|
|
45
|
+
*/
|
|
46
|
+
export declare function buildChannelFileContentParts(ref: ChannelMessageRef, creds: GraphCredentials, config?: FileDeliveryConfig): Promise<{
|
|
47
|
+
parts: AgentContentPart[];
|
|
48
|
+
notes: string[];
|
|
49
|
+
}>;
|
|
50
|
+
//# sourceMappingURL=graph-files.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"graph-files.d.ts","sourceRoot":"","sources":["../src/graph-files.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,OAAO,EAGL,KAAK,kBAAkB,EACxB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAEhE,oEAAoE;AACpE,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,8EAA8E;AAC9E,MAAM,WAAW,iBAAiB;IAChC,uEAAuE;IACvE,MAAM,EAAE,MAAM,CAAC;IACf,4DAA4D;IAC5D,SAAS,EAAE,MAAM,CAAC;IAClB,8CAA8C;IAC9C,SAAS,EAAE,MAAM,CAAC;IAClB,qFAAqF;IACrF,MAAM,EAAE,MAAM,CAAC;CAChB;AAwGD;;;;GAIG;AACH,wBAAsB,4BAA4B,CAChD,GAAG,EAAE,iBAAiB,EACtB,KAAK,EAAE,gBAAgB,EACvB,MAAM,GAAE,kBAAuB,GAC9B,OAAO,CAAC;IAAE,KAAK,EAAE,gBAAgB,EAAE,CAAC;IAAC,KAAK,EAAE,MAAM,EAAE,CAAA;CAAE,CAAC,CAwCzD"}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Microsoft Graph file access for Teams CHANNEL messages.
|
|
3
|
+
*
|
|
4
|
+
* The Teams bot file API (the `file.download.info` attachment) only fires in a
|
|
5
|
+
* 1:1 personal chat. In a channel the bot's inbound activity carries no file at
|
|
6
|
+
* all — the upload lives in the team's SharePoint document library and is only
|
|
7
|
+
* referenced from the channel *message*, which the bot doesn't receive inline.
|
|
8
|
+
* So to chart "a CSV someone dropped in a channel" we go through Graph:
|
|
9
|
+
*
|
|
10
|
+
* 1. Acquire an app-only Graph token (client credentials — the same
|
|
11
|
+
* clientId/clientSecret/tenantId the adapter already uses for Teams).
|
|
12
|
+
* 2. Read the channel message to find its file attachments
|
|
13
|
+
* (`GET /teams/{team}/channels/{channel}/messages/{id}` — app-only via the
|
|
14
|
+
* RSC permission `ChannelMessage.Read.Group`, declared in the app
|
|
15
|
+
* manifest; no tenant admin consent or protected-API approval needed).
|
|
16
|
+
* 3. Download each referenced SharePoint file via the `/shares` endpoint
|
|
17
|
+
* (needs the `Files.Read.All` application permission + admin consent).
|
|
18
|
+
*
|
|
19
|
+
* Returns nothing (with a note) when Graph isn't configured or a step fails, so
|
|
20
|
+
* the bot degrades to asking the user to paste the data rather than crashing.
|
|
21
|
+
*/
|
|
22
|
+
import { decodeFileBytes, mimeFromName, } from "./download-files.js";
|
|
23
|
+
const GRAPH = "https://graph.microsoft.com/v1.0";
|
|
24
|
+
// One cached app token per process; Graph tokens last ~1h. Date.now is fine here
|
|
25
|
+
// (this is ordinary runtime code, not a replay-sensitive workflow script).
|
|
26
|
+
let cachedToken;
|
|
27
|
+
/** Acquire (and cache) an app-only Graph token via the client-credentials grant. */
|
|
28
|
+
async function acquireAppToken(creds) {
|
|
29
|
+
const now = Date.now();
|
|
30
|
+
if (cachedToken && cachedToken.expiresAt > now + 60_000) {
|
|
31
|
+
return cachedToken.token;
|
|
32
|
+
}
|
|
33
|
+
const body = new URLSearchParams({
|
|
34
|
+
client_id: creds.clientId,
|
|
35
|
+
client_secret: creds.clientSecret,
|
|
36
|
+
scope: "https://graph.microsoft.com/.default",
|
|
37
|
+
grant_type: "client_credentials",
|
|
38
|
+
});
|
|
39
|
+
const res = await fetch(`https://login.microsoftonline.com/${creds.tenantId}/oauth2/v2.0/token`, {
|
|
40
|
+
method: "POST",
|
|
41
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
42
|
+
body,
|
|
43
|
+
});
|
|
44
|
+
if (!res.ok) {
|
|
45
|
+
throw new Error(`Graph token request failed (HTTP ${res.status}): ${(await res.text()).slice(0, 200)}`);
|
|
46
|
+
}
|
|
47
|
+
const json = (await res.json());
|
|
48
|
+
cachedToken = {
|
|
49
|
+
token: json.access_token,
|
|
50
|
+
expiresAt: now + json.expires_in * 1000,
|
|
51
|
+
};
|
|
52
|
+
return cachedToken.token;
|
|
53
|
+
}
|
|
54
|
+
/** Encode a sharing URL into a Graph `/shares` share id (`u!<base64url>`). */
|
|
55
|
+
function shareIdFor(url) {
|
|
56
|
+
const b64 = Buffer.from(url, "utf8").toString("base64");
|
|
57
|
+
return "u!" + b64.replace(/=+$/, "").replace(/\//g, "_").replace(/\+/g, "-");
|
|
58
|
+
}
|
|
59
|
+
/** Read a channel message and return its file-reference attachments. */
|
|
60
|
+
async function getMessageFileRefs(ref, token) {
|
|
61
|
+
const channel = encodeURIComponent(ref.channelId);
|
|
62
|
+
const url = ref.rootId && ref.rootId !== ref.messageId
|
|
63
|
+
? `${GRAPH}/teams/${ref.teamId}/channels/${channel}/messages/${ref.rootId}/replies/${ref.messageId}`
|
|
64
|
+
: `${GRAPH}/teams/${ref.teamId}/channels/${channel}/messages/${ref.messageId}`;
|
|
65
|
+
const res = await fetch(url, {
|
|
66
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
67
|
+
});
|
|
68
|
+
if (!res.ok) {
|
|
69
|
+
throw new Error(`read channel message failed (HTTP ${res.status}): ${(await res.text()).slice(0, 200)}`);
|
|
70
|
+
}
|
|
71
|
+
const msg = (await res.json());
|
|
72
|
+
return (msg.attachments ?? [])
|
|
73
|
+
.filter((a) => a.contentType === "reference" && typeof a.contentUrl === "string")
|
|
74
|
+
.map((a) => ({ name: a.name ?? "file", contentUrl: a.contentUrl }));
|
|
75
|
+
}
|
|
76
|
+
/** Download a SharePoint file by its sharing URL via the Graph `/shares` API. */
|
|
77
|
+
async function downloadSharedFile(contentUrl, token) {
|
|
78
|
+
const res = await fetch(`${GRAPH}/shares/${shareIdFor(contentUrl)}/driveItem/content`, { headers: { Authorization: `Bearer ${token}` }, redirect: "follow" });
|
|
79
|
+
if (!res.ok) {
|
|
80
|
+
throw new Error(`download failed (HTTP ${res.status})`);
|
|
81
|
+
}
|
|
82
|
+
return Buffer.from(await res.arrayBuffer());
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Resolve a channel message's uploaded files into AG-UI content parts via
|
|
86
|
+
* Graph. Returns `{ parts, notes }`; on any failure the parts are empty and a
|
|
87
|
+
* note explains why (so the caller can tell the user to paste the data).
|
|
88
|
+
*/
|
|
89
|
+
export async function buildChannelFileContentParts(ref, creds, config = {}) {
|
|
90
|
+
const parts = [];
|
|
91
|
+
const notes = [];
|
|
92
|
+
let token;
|
|
93
|
+
try {
|
|
94
|
+
token = await acquireAppToken(creds);
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
notes.push(`couldn't get a Graph token: ${err.message}`);
|
|
98
|
+
return { parts, notes };
|
|
99
|
+
}
|
|
100
|
+
let refs;
|
|
101
|
+
try {
|
|
102
|
+
refs = await getMessageFileRefs(ref, token);
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
notes.push(`couldn't read channel-message files via Graph (is ChannelMessage.Read.Group granted?): ${err.message}`);
|
|
106
|
+
return { parts, notes };
|
|
107
|
+
}
|
|
108
|
+
const maxFiles = config.maxFiles ?? 5;
|
|
109
|
+
for (const f of refs.slice(0, maxFiles)) {
|
|
110
|
+
let bytes;
|
|
111
|
+
try {
|
|
112
|
+
bytes = await downloadSharedFile(f.contentUrl, token);
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
notes.push(`skipped "${f.name}" (is Files.Read.All granted?): ${err.message}`);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const mime = mimeFromName(f.name) ?? "application/octet-stream";
|
|
119
|
+
const decoded = decodeFileBytes(f.name, mime, bytes, config);
|
|
120
|
+
if ("note" in decoded)
|
|
121
|
+
notes.push(decoded.note);
|
|
122
|
+
else
|
|
123
|
+
parts.push(decoded.part);
|
|
124
|
+
}
|
|
125
|
+
return { parts, notes };
|
|
126
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"graph-files.test.d.ts","sourceRoot":"","sources":["../src/graph-files.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
2
|
+
import { buildChannelFileContentParts } from "./graph-files.js";
|
|
3
|
+
const creds = {
|
|
4
|
+
clientId: "app-1",
|
|
5
|
+
clientSecret: "secret",
|
|
6
|
+
tenantId: "tenant-1",
|
|
7
|
+
};
|
|
8
|
+
const ref = {
|
|
9
|
+
teamId: "team-1",
|
|
10
|
+
channelId: "19:abc@thread.tacv2",
|
|
11
|
+
messageId: "100",
|
|
12
|
+
rootId: "100",
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Route fetch by URL: the token endpoint, the Graph message read, and the
|
|
16
|
+
* SharePoint download all hit different hosts/paths.
|
|
17
|
+
*/
|
|
18
|
+
function routeFetch(handlers) {
|
|
19
|
+
vi.stubGlobal("fetch", vi.fn(async (url) => {
|
|
20
|
+
if (url.includes("/oauth2/v2.0/token")) {
|
|
21
|
+
return {
|
|
22
|
+
ok: true,
|
|
23
|
+
status: 200,
|
|
24
|
+
json: async () => ({ access_token: "tok", expires_in: 3600 }),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
if (url.includes("/messages/")) {
|
|
28
|
+
return {
|
|
29
|
+
ok: true,
|
|
30
|
+
status: 200,
|
|
31
|
+
json: async () => handlers.message,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
// /shares/.../driveItem/content
|
|
35
|
+
const d = handlers.download ?? {};
|
|
36
|
+
return {
|
|
37
|
+
ok: d.ok ?? true,
|
|
38
|
+
status: d.status ?? 200,
|
|
39
|
+
arrayBuffer: async () => (d.body ?? new Uint8Array()).buffer,
|
|
40
|
+
text: async () => "",
|
|
41
|
+
};
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
45
|
+
describe("buildChannelFileContentParts (Graph)", () => {
|
|
46
|
+
it("reads a channel message's reference attachment and downloads the CSV", async () => {
|
|
47
|
+
routeFetch({
|
|
48
|
+
message: {
|
|
49
|
+
attachments: [
|
|
50
|
+
{
|
|
51
|
+
contentType: "reference",
|
|
52
|
+
contentUrl: "https://contoso.sharepoint.com/sites/team/Shared Documents/incidents.csv",
|
|
53
|
+
name: "incidents.csv",
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
download: { body: new TextEncoder().encode("severity,count\nSev1,4") },
|
|
58
|
+
});
|
|
59
|
+
const { parts, notes } = await buildChannelFileContentParts(ref, creds);
|
|
60
|
+
expect(notes).toEqual([]);
|
|
61
|
+
expect(parts).toHaveLength(1);
|
|
62
|
+
const text = parts[0].text;
|
|
63
|
+
expect(text).toContain('Attached file "incidents.csv" (text/csv)');
|
|
64
|
+
expect(text).toContain("severity,count");
|
|
65
|
+
});
|
|
66
|
+
it("ignores non-file (mention/html) attachments", async () => {
|
|
67
|
+
routeFetch({
|
|
68
|
+
message: {
|
|
69
|
+
attachments: [
|
|
70
|
+
{ contentType: "messageReference", content: "{}" },
|
|
71
|
+
{ contentType: "reference" }, // no contentUrl
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
const { parts, notes } = await buildChannelFileContentParts(ref, creds);
|
|
76
|
+
expect(parts).toEqual([]);
|
|
77
|
+
expect(notes).toEqual([]);
|
|
78
|
+
});
|
|
79
|
+
it("notes a download permission failure (so the caller can react)", async () => {
|
|
80
|
+
routeFetch({
|
|
81
|
+
message: {
|
|
82
|
+
attachments: [
|
|
83
|
+
{
|
|
84
|
+
contentType: "reference",
|
|
85
|
+
contentUrl: "https://contoso.sharepoint.com/sites/team/x.csv",
|
|
86
|
+
name: "x.csv",
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
},
|
|
90
|
+
download: { ok: false, status: 403 },
|
|
91
|
+
});
|
|
92
|
+
const { parts, notes } = await buildChannelFileContentParts(ref, creds);
|
|
93
|
+
expect(parts).toEqual([]);
|
|
94
|
+
expect(notes[0]).toContain("Files.Read.All");
|
|
95
|
+
});
|
|
96
|
+
it("notes when the channel-message read fails (e.g. RSC not granted)", async () => {
|
|
97
|
+
vi.stubGlobal("fetch", vi.fn(async (url) => {
|
|
98
|
+
if (url.includes("/oauth2/v2.0/token")) {
|
|
99
|
+
return {
|
|
100
|
+
ok: true,
|
|
101
|
+
json: async () => ({ access_token: "t", expires_in: 3600 }),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
ok: false,
|
|
106
|
+
status: 403,
|
|
107
|
+
text: async () => "forbidden",
|
|
108
|
+
};
|
|
109
|
+
}));
|
|
110
|
+
const { parts, notes } = await buildChannelFileContentParts(ref, creds);
|
|
111
|
+
expect(parts).toEqual([]);
|
|
112
|
+
expect(notes[0]).toContain("ChannelMessage.Read.Group");
|
|
113
|
+
});
|
|
114
|
+
});
|