@openclaw/msteams 2026.3.13 → 2026.5.2-beta.1
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/api.ts +3 -0
- package/channel-config-api.ts +1 -0
- package/channel-plugin-api.ts +2 -0
- package/config-api.ts +4 -0
- package/contract-api.ts +4 -0
- package/index.ts +15 -12
- package/openclaw.plugin.json +553 -1
- package/package.json +46 -12
- package/runtime-api.ts +73 -0
- package/secret-contract-api.ts +5 -0
- package/setup-entry.ts +13 -0
- package/setup-plugin-api.ts +3 -0
- package/src/ai-entity.ts +7 -0
- package/src/approval-auth.ts +44 -0
- package/src/attachments/bot-framework.test.ts +461 -0
- package/src/attachments/bot-framework.ts +362 -0
- package/src/attachments/download.ts +63 -19
- package/src/attachments/graph.test.ts +416 -0
- package/src/attachments/graph.ts +163 -72
- package/src/attachments/html.ts +33 -1
- package/src/attachments/payload.ts +1 -1
- package/src/attachments/remote-media.test.ts +137 -0
- package/src/attachments/remote-media.ts +75 -8
- package/src/attachments/shared.test.ts +138 -1
- package/src/attachments/shared.ts +193 -26
- package/src/attachments/types.ts +10 -0
- package/src/attachments.graph.test.ts +342 -0
- package/src/attachments.helpers.test.ts +246 -0
- package/src/attachments.test-helpers.ts +17 -0
- package/src/attachments.test.ts +163 -418
- package/src/attachments.ts +5 -5
- package/src/block-streaming-config.test.ts +61 -0
- package/src/channel-api.ts +1 -0
- package/src/channel.actions.test.ts +742 -0
- package/src/channel.directory.test.ts +145 -4
- package/src/channel.runtime.ts +56 -0
- package/src/channel.setup.ts +77 -0
- package/src/channel.test.ts +128 -0
- package/src/channel.ts +1077 -395
- package/src/config-schema.ts +6 -0
- package/src/config-ui-hints.ts +12 -0
- package/src/conversation-store-fs.test.ts +4 -5
- package/src/conversation-store-fs.ts +35 -51
- package/src/conversation-store-helpers.test.ts +202 -0
- package/src/conversation-store-helpers.ts +105 -0
- package/src/conversation-store-memory.ts +27 -23
- package/src/conversation-store.shared.test.ts +225 -0
- package/src/conversation-store.ts +30 -0
- package/src/directory-live.test.ts +156 -0
- package/src/directory-live.ts +7 -4
- package/src/doctor.ts +27 -0
- package/src/errors.test.ts +64 -1
- package/src/errors.ts +50 -9
- package/src/feedback-reflection-prompt.ts +117 -0
- package/src/feedback-reflection-store.ts +114 -0
- package/src/feedback-reflection.test.ts +237 -0
- package/src/feedback-reflection.ts +283 -0
- package/src/file-consent-helpers.test.ts +83 -0
- package/src/file-consent-helpers.ts +64 -11
- package/src/file-consent-invoke.ts +150 -0
- package/src/file-consent.test.ts +363 -0
- package/src/file-consent.ts +165 -4
- package/src/graph-chat.ts +5 -3
- package/src/graph-group-management.test.ts +318 -0
- package/src/graph-group-management.ts +168 -0
- package/src/graph-members.test.ts +89 -0
- package/src/graph-members.ts +48 -0
- package/src/graph-messages.actions.test.ts +243 -0
- package/src/graph-messages.read.test.ts +391 -0
- package/src/graph-messages.search.test.ts +213 -0
- package/src/graph-messages.test-helpers.ts +50 -0
- package/src/graph-messages.ts +534 -0
- package/src/graph-teams.test.ts +215 -0
- package/src/graph-teams.ts +114 -0
- package/src/graph-thread.test.ts +246 -0
- package/src/graph-thread.ts +146 -0
- package/src/graph-upload.test.ts +161 -4
- package/src/graph-upload.ts +147 -56
- package/src/graph.test.ts +516 -0
- package/src/graph.ts +233 -21
- package/src/inbound.test.ts +156 -1
- package/src/inbound.ts +101 -1
- package/src/media-helpers.ts +1 -1
- package/src/mentions.test.ts +27 -18
- package/src/mentions.ts +2 -2
- package/src/messenger.test.ts +504 -23
- package/src/messenger.ts +133 -52
- package/src/monitor-handler/access.ts +125 -0
- package/src/monitor-handler/inbound-media.test.ts +289 -0
- package/src/monitor-handler/inbound-media.ts +57 -5
- package/src/monitor-handler/message-handler-mock-support.test-support.ts +28 -0
- package/src/monitor-handler/message-handler.authz.test.ts +588 -74
- package/src/monitor-handler/message-handler.dm-media.test.ts +54 -0
- package/src/monitor-handler/message-handler.test-support.ts +100 -0
- package/src/monitor-handler/message-handler.thread-parent.test.ts +223 -0
- package/src/monitor-handler/message-handler.thread-session.test.ts +77 -0
- package/src/monitor-handler/message-handler.ts +470 -164
- package/src/monitor-handler/reaction-handler.test.ts +267 -0
- package/src/monitor-handler/reaction-handler.ts +210 -0
- package/src/monitor-handler/thread-session.ts +17 -0
- package/src/monitor-handler.adaptive-card.test.ts +162 -0
- package/src/monitor-handler.feedback-authz.test.ts +314 -0
- package/src/monitor-handler.file-consent.test.ts +281 -79
- package/src/monitor-handler.sso.test.ts +563 -0
- package/src/monitor-handler.test-helpers.ts +180 -0
- package/src/monitor-handler.ts +459 -115
- package/src/monitor-handler.types.ts +27 -0
- package/src/monitor-types.ts +1 -0
- package/src/monitor.lifecycle.test.ts +74 -10
- package/src/monitor.test.ts +35 -1
- package/src/monitor.ts +143 -46
- package/src/oauth.flow.ts +77 -0
- package/src/oauth.shared.ts +37 -0
- package/src/oauth.test.ts +305 -0
- package/src/oauth.token.ts +158 -0
- package/src/oauth.ts +130 -0
- package/src/outbound.test.ts +10 -11
- package/src/outbound.ts +62 -44
- package/src/pending-uploads-fs.test.ts +246 -0
- package/src/pending-uploads-fs.ts +235 -0
- package/src/pending-uploads.test.ts +173 -0
- package/src/pending-uploads.ts +34 -2
- package/src/policy.test.ts +11 -5
- package/src/policy.ts +5 -5
- package/src/polls.test.ts +106 -5
- package/src/polls.ts +15 -7
- package/src/presentation.ts +68 -0
- package/src/probe.test.ts +27 -8
- package/src/probe.ts +43 -9
- package/src/reply-dispatcher.test.ts +437 -0
- package/src/reply-dispatcher.ts +259 -73
- package/src/reply-stream-controller.test.ts +235 -0
- package/src/reply-stream-controller.ts +147 -0
- package/src/resolve-allowlist.test.ts +105 -1
- package/src/resolve-allowlist.ts +112 -7
- package/src/runtime.ts +6 -3
- package/src/sdk-types.ts +43 -3
- package/src/sdk.test.ts +666 -0
- package/src/sdk.ts +867 -16
- package/src/secret-contract.ts +49 -0
- package/src/secret-input.ts +1 -1
- package/src/send-context.ts +76 -9
- package/src/send.test.ts +389 -5
- package/src/send.ts +140 -32
- package/src/sent-message-cache.ts +30 -18
- package/src/session-route.ts +40 -0
- package/src/setup-core.ts +160 -0
- package/src/setup-surface.test.ts +202 -0
- package/src/setup-surface.ts +320 -0
- package/src/sso-token-store.test.ts +72 -0
- package/src/sso-token-store.ts +166 -0
- package/src/sso.ts +300 -0
- package/src/storage.ts +1 -1
- package/src/store-fs.ts +2 -2
- package/src/streaming-message.test.ts +262 -0
- package/src/streaming-message.ts +297 -0
- package/src/test-runtime.ts +1 -1
- package/src/thread-parent-context.test.ts +224 -0
- package/src/thread-parent-context.ts +159 -0
- package/src/token.test.ts +237 -50
- package/src/token.ts +162 -7
- package/src/user-agent.test.ts +86 -0
- package/src/user-agent.ts +53 -0
- package/src/webhook-timeouts.ts +27 -0
- package/src/welcome-card.test.ts +81 -0
- package/src/welcome-card.ts +57 -0
- package/test-api.ts +1 -0
- package/tsconfig.json +16 -0
- package/CHANGELOG.md +0 -107
- package/src/file-lock.ts +0 -1
- package/src/graph-users.test.ts +0 -66
- package/src/onboarding.ts +0 -381
- package/src/polls-store.test.ts +0 -38
- package/src/revoked-context.test.ts +0 -39
- package/src/token-response.test.ts +0 -23
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { TeamsHttpStream } from "./streaming-message.js";
|
|
3
|
+
|
|
4
|
+
async function flushStreamTimer(): Promise<void> {
|
|
5
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
describe("TeamsHttpStream", () => {
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
vi.useRealTimers();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("sends first chunk as typing activity with streaminfo", async () => {
|
|
14
|
+
vi.useFakeTimers();
|
|
15
|
+
|
|
16
|
+
const sent: unknown[] = [];
|
|
17
|
+
const stream = new TeamsHttpStream({
|
|
18
|
+
sendActivity: vi.fn(async (activity) => {
|
|
19
|
+
sent.push(activity);
|
|
20
|
+
return { id: "stream-1" };
|
|
21
|
+
}),
|
|
22
|
+
throttleMs: 1,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// Enough text to pass MIN_INITIAL_CHARS threshold
|
|
26
|
+
stream.update("Hello, this is a test response that is long enough.");
|
|
27
|
+
await flushStreamTimer();
|
|
28
|
+
|
|
29
|
+
expect(sent.length).toBeGreaterThanOrEqual(1);
|
|
30
|
+
const firstActivity = sent[0] as Record<string, unknown>;
|
|
31
|
+
expect(firstActivity.type).toBe("typing");
|
|
32
|
+
expect(typeof firstActivity.text).toBe("string");
|
|
33
|
+
expect(firstActivity.text as string).toContain("Hello");
|
|
34
|
+
// Should have streaminfo entity
|
|
35
|
+
const entities = firstActivity.entities as Array<Record<string, unknown>>;
|
|
36
|
+
expect(entities).toEqual(
|
|
37
|
+
expect.arrayContaining([
|
|
38
|
+
expect.objectContaining({ type: "streaminfo", streamType: "streaming" }),
|
|
39
|
+
]),
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("sends final message activity on finalize", async () => {
|
|
44
|
+
vi.useFakeTimers();
|
|
45
|
+
|
|
46
|
+
const sent: unknown[] = [];
|
|
47
|
+
const stream = new TeamsHttpStream({
|
|
48
|
+
sendActivity: vi.fn(async (activity) => {
|
|
49
|
+
sent.push(activity);
|
|
50
|
+
return { id: "stream-1" };
|
|
51
|
+
}),
|
|
52
|
+
throttleMs: 1,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
stream.update("Hello, this is a complete response for finalization testing.");
|
|
56
|
+
await flushStreamTimer();
|
|
57
|
+
|
|
58
|
+
await stream.finalize();
|
|
59
|
+
|
|
60
|
+
// Find the final message activity
|
|
61
|
+
const finalActivity = sent.find((a) => (a as Record<string, unknown>).type === "message") as
|
|
62
|
+
| Record<string, unknown>
|
|
63
|
+
| undefined;
|
|
64
|
+
|
|
65
|
+
expect(finalActivity).toBeDefined();
|
|
66
|
+
expect(finalActivity!.text).toBe(
|
|
67
|
+
"Hello, this is a complete response for finalization testing.",
|
|
68
|
+
);
|
|
69
|
+
// No cursor in final
|
|
70
|
+
expect(finalActivity!.text as string).not.toContain("\u258D");
|
|
71
|
+
|
|
72
|
+
// Should have AI-generated entity
|
|
73
|
+
const entities = finalActivity!.entities as Array<Record<string, unknown>>;
|
|
74
|
+
expect(entities).toEqual(
|
|
75
|
+
expect.arrayContaining([expect.objectContaining({ additionalType: ["AIGeneratedContent"] })]),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
// Should have streaminfo with final type
|
|
79
|
+
expect(entities).toEqual(
|
|
80
|
+
expect.arrayContaining([
|
|
81
|
+
expect.objectContaining({ type: "streaminfo", streamType: "final" }),
|
|
82
|
+
]),
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("does not send below MIN_INITIAL_CHARS", async () => {
|
|
87
|
+
vi.useFakeTimers();
|
|
88
|
+
|
|
89
|
+
const sendActivity = vi.fn(async () => ({ id: "x" }));
|
|
90
|
+
const stream = new TeamsHttpStream({ sendActivity, throttleMs: 1 });
|
|
91
|
+
|
|
92
|
+
stream.update("Hi");
|
|
93
|
+
await flushStreamTimer();
|
|
94
|
+
|
|
95
|
+
expect(sendActivity).not.toHaveBeenCalled();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("finalize with no content does nothing", async () => {
|
|
99
|
+
const sendActivity = vi.fn(async () => ({ id: "x" }));
|
|
100
|
+
const stream = new TeamsHttpStream({ sendActivity });
|
|
101
|
+
|
|
102
|
+
await stream.finalize();
|
|
103
|
+
expect(sendActivity).not.toHaveBeenCalled();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("finalize sends content even if no chunks were streamed", async () => {
|
|
107
|
+
const sent: unknown[] = [];
|
|
108
|
+
const stream = new TeamsHttpStream({
|
|
109
|
+
sendActivity: vi.fn(async (activity) => {
|
|
110
|
+
sent.push(activity);
|
|
111
|
+
return { id: "msg-1" };
|
|
112
|
+
}),
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// Short text — below MIN_INITIAL_CHARS, so no streaming chunk sent
|
|
116
|
+
stream.update("Short");
|
|
117
|
+
await stream.finalize();
|
|
118
|
+
|
|
119
|
+
// Should send final message even though no chunks were streamed
|
|
120
|
+
expect(sent.length).toBe(1);
|
|
121
|
+
const activity = sent[0] as Record<string, unknown>;
|
|
122
|
+
expect(activity.type).toBe("message");
|
|
123
|
+
expect(activity.text).toBe("Short");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("sets feedbackLoopEnabled on final message", async () => {
|
|
127
|
+
vi.useFakeTimers();
|
|
128
|
+
|
|
129
|
+
const sent: unknown[] = [];
|
|
130
|
+
const stream = new TeamsHttpStream({
|
|
131
|
+
sendActivity: vi.fn(async (activity) => {
|
|
132
|
+
sent.push(activity);
|
|
133
|
+
return { id: "stream-1" };
|
|
134
|
+
}),
|
|
135
|
+
feedbackLoopEnabled: true,
|
|
136
|
+
throttleMs: 1,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
stream.update("A response long enough to pass the minimum character threshold for streaming.");
|
|
140
|
+
await flushStreamTimer();
|
|
141
|
+
await stream.finalize();
|
|
142
|
+
|
|
143
|
+
const finalActivity = sent.find(
|
|
144
|
+
(a) => (a as Record<string, unknown>).type === "message",
|
|
145
|
+
) as Record<string, unknown>;
|
|
146
|
+
|
|
147
|
+
const channelData = finalActivity.channelData as Record<string, unknown>;
|
|
148
|
+
expect(channelData.feedbackLoopEnabled).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("sends informative update with streamType informative", async () => {
|
|
152
|
+
const sent: unknown[] = [];
|
|
153
|
+
const stream = new TeamsHttpStream({
|
|
154
|
+
sendActivity: vi.fn(async (activity) => {
|
|
155
|
+
sent.push(activity);
|
|
156
|
+
return { id: "stream-1" };
|
|
157
|
+
}),
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
await stream.sendInformativeUpdate("Thinking...");
|
|
161
|
+
|
|
162
|
+
expect(sent.length).toBe(1);
|
|
163
|
+
const activity = sent[0] as Record<string, unknown>;
|
|
164
|
+
expect(activity.type).toBe("typing");
|
|
165
|
+
expect(activity.text).toBe("Thinking...");
|
|
166
|
+
const entities = activity.entities as Array<Record<string, unknown>>;
|
|
167
|
+
expect(entities).toEqual(
|
|
168
|
+
expect.arrayContaining([
|
|
169
|
+
expect.objectContaining({
|
|
170
|
+
type: "streaminfo",
|
|
171
|
+
streamType: "informative",
|
|
172
|
+
streamSequence: 1,
|
|
173
|
+
}),
|
|
174
|
+
]),
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("informative update establishes streamId for subsequent chunks", async () => {
|
|
179
|
+
vi.useFakeTimers();
|
|
180
|
+
|
|
181
|
+
const sent: unknown[] = [];
|
|
182
|
+
const stream = new TeamsHttpStream({
|
|
183
|
+
sendActivity: vi.fn(async (activity) => {
|
|
184
|
+
sent.push(activity);
|
|
185
|
+
return { id: "stream-1" };
|
|
186
|
+
}),
|
|
187
|
+
throttleMs: 1,
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
await stream.sendInformativeUpdate("Working...");
|
|
191
|
+
stream.update("Hello, this is a long enough response for streaming to begin.");
|
|
192
|
+
await flushStreamTimer();
|
|
193
|
+
|
|
194
|
+
// Second activity (streaming chunk) should have the streamId from the informative update
|
|
195
|
+
expect(sent.length).toBeGreaterThanOrEqual(2);
|
|
196
|
+
const chunk = sent[1] as Record<string, unknown>;
|
|
197
|
+
const entities = chunk.entities as Array<Record<string, unknown>>;
|
|
198
|
+
expect(entities).toEqual(
|
|
199
|
+
expect.arrayContaining([
|
|
200
|
+
expect.objectContaining({ type: "streaminfo", streamId: "stream-1" }),
|
|
201
|
+
]),
|
|
202
|
+
);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("hasContent is true after update", () => {
|
|
206
|
+
const stream = new TeamsHttpStream({
|
|
207
|
+
sendActivity: vi.fn(async () => ({ id: "x" })),
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
expect(stream.hasContent).toBe(false);
|
|
211
|
+
stream.update("some text");
|
|
212
|
+
expect(stream.hasContent).toBe(true);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("double finalize is a no-op", async () => {
|
|
216
|
+
const sendActivity = vi.fn(async () => ({ id: "x" }));
|
|
217
|
+
const stream = new TeamsHttpStream({ sendActivity });
|
|
218
|
+
|
|
219
|
+
stream.update("A response long enough to pass the minimum character threshold.");
|
|
220
|
+
await stream.finalize();
|
|
221
|
+
const callCount = sendActivity.mock.calls.length;
|
|
222
|
+
|
|
223
|
+
await stream.finalize();
|
|
224
|
+
expect(sendActivity.mock.calls.length).toBe(callCount);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("stops streaming before stream age timeout and finalizes with last good text", async () => {
|
|
228
|
+
vi.useFakeTimers();
|
|
229
|
+
|
|
230
|
+
const sent: unknown[] = [];
|
|
231
|
+
const sendActivity = vi.fn(async (activity) => {
|
|
232
|
+
sent.push(activity);
|
|
233
|
+
return { id: "stream-1" };
|
|
234
|
+
});
|
|
235
|
+
const stream = new TeamsHttpStream({ sendActivity, throttleMs: 1 });
|
|
236
|
+
|
|
237
|
+
stream.update("Hello, this is a long enough response for streaming to begin.");
|
|
238
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
239
|
+
|
|
240
|
+
stream.update(
|
|
241
|
+
"Hello, this is a long enough response for streaming to begin. More text before timeout.",
|
|
242
|
+
);
|
|
243
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
244
|
+
|
|
245
|
+
vi.setSystemTime(new Date(Date.now() + 45_001));
|
|
246
|
+
stream.update(
|
|
247
|
+
"Hello, this is a long enough response for streaming to begin. More text before timeout. Even more text after timeout.",
|
|
248
|
+
);
|
|
249
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
250
|
+
|
|
251
|
+
expect(stream.isFailed).toBe(true);
|
|
252
|
+
|
|
253
|
+
const finalActivity = sent.find((a) => (a as Record<string, unknown>).type === "message") as
|
|
254
|
+
| Record<string, unknown>
|
|
255
|
+
| undefined;
|
|
256
|
+
|
|
257
|
+
expect(finalActivity).toBeDefined();
|
|
258
|
+
expect(finalActivity!.text).toBe(
|
|
259
|
+
"Hello, this is a long enough response for streaming to begin. More text before timeout.",
|
|
260
|
+
);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Teams streaming message using the streaminfo entity protocol.
|
|
3
|
+
*
|
|
4
|
+
* Follows the official Teams SDK pattern:
|
|
5
|
+
* 1. First chunk → POST a typing activity with streaminfo entity (streamType: "streaming")
|
|
6
|
+
* 2. Subsequent chunks → POST typing activities with streaminfo + incrementing streamSequence
|
|
7
|
+
* 3. Finalize → POST a message activity with streaminfo (streamType: "final")
|
|
8
|
+
*
|
|
9
|
+
* Uses the shared draft-stream-loop for throttling (avoids rate limits).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { createDraftStreamLoop, type DraftStreamLoop } from "openclaw/plugin-sdk/channel-lifecycle";
|
|
13
|
+
import { readStringValue } from "openclaw/plugin-sdk/text-runtime";
|
|
14
|
+
|
|
15
|
+
/** Default throttle interval between stream updates (ms).
|
|
16
|
+
* Teams docs recommend buffering tokens for 1.5-2s; limit is 1 req/s. */
|
|
17
|
+
const DEFAULT_THROTTLE_MS = 1500;
|
|
18
|
+
|
|
19
|
+
/** Minimum chars before sending the first streaming message. */
|
|
20
|
+
const MIN_INITIAL_CHARS = 20;
|
|
21
|
+
|
|
22
|
+
/** Teams message text limit. */
|
|
23
|
+
const TEAMS_MAX_CHARS = 4000;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Stop streaming before Teams expires the content stream server-side.
|
|
27
|
+
* The exact service limit is opaque, so stay comfortably under it.
|
|
28
|
+
*/
|
|
29
|
+
const MAX_STREAM_AGE_MS = 45_000;
|
|
30
|
+
|
|
31
|
+
type StreamSendFn = (activity: Record<string, unknown>) => Promise<unknown>;
|
|
32
|
+
|
|
33
|
+
type TeamsStreamOptions = {
|
|
34
|
+
/** Function to send an activity (POST to Bot Framework). */
|
|
35
|
+
sendActivity: StreamSendFn;
|
|
36
|
+
/** Whether to enable feedback loop on the final message. */
|
|
37
|
+
feedbackLoopEnabled?: boolean;
|
|
38
|
+
/** Throttle interval in ms. Default: 600. */
|
|
39
|
+
throttleMs?: number;
|
|
40
|
+
/** Called on errors during streaming. */
|
|
41
|
+
onError?: (err: unknown) => void;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
import { AI_GENERATED_ENTITY } from "./ai-entity.js";
|
|
45
|
+
import { formatUnknownError } from "./errors.js";
|
|
46
|
+
|
|
47
|
+
function extractId(response: unknown): string | undefined {
|
|
48
|
+
if (response && typeof response === "object" && "id" in response) {
|
|
49
|
+
return readStringValue((response as { id?: unknown }).id);
|
|
50
|
+
}
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function buildStreamInfoEntity(
|
|
55
|
+
streamId: string | undefined,
|
|
56
|
+
streamType: "informative" | "streaming" | "final",
|
|
57
|
+
streamSequence?: number,
|
|
58
|
+
): Record<string, unknown> {
|
|
59
|
+
const entity: Record<string, unknown> = {
|
|
60
|
+
type: "streaminfo",
|
|
61
|
+
streamType,
|
|
62
|
+
};
|
|
63
|
+
// streamId is only present after the first chunk (returned by the service)
|
|
64
|
+
if (streamId) {
|
|
65
|
+
entity.streamId = streamId;
|
|
66
|
+
}
|
|
67
|
+
// streamSequence must be present for start/continue, but NOT for final
|
|
68
|
+
if (streamSequence != null) {
|
|
69
|
+
entity.streamSequence = streamSequence;
|
|
70
|
+
}
|
|
71
|
+
return entity;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export class TeamsHttpStream {
|
|
75
|
+
private sendActivity: StreamSendFn;
|
|
76
|
+
private feedbackLoopEnabled: boolean;
|
|
77
|
+
private onError?: (err: unknown) => void;
|
|
78
|
+
|
|
79
|
+
private accumulatedText = "";
|
|
80
|
+
private streamId: string | undefined = undefined;
|
|
81
|
+
private sequenceNumber = 0;
|
|
82
|
+
private stopped = false;
|
|
83
|
+
private finalized = false;
|
|
84
|
+
private streamFailed = false;
|
|
85
|
+
private lastStreamedText = "";
|
|
86
|
+
private streamStartedAt: number | undefined = undefined;
|
|
87
|
+
private loop: DraftStreamLoop;
|
|
88
|
+
|
|
89
|
+
constructor(options: TeamsStreamOptions) {
|
|
90
|
+
this.sendActivity = options.sendActivity;
|
|
91
|
+
this.feedbackLoopEnabled = options.feedbackLoopEnabled ?? false;
|
|
92
|
+
this.onError = options.onError;
|
|
93
|
+
|
|
94
|
+
this.loop = createDraftStreamLoop({
|
|
95
|
+
throttleMs: options.throttleMs ?? DEFAULT_THROTTLE_MS,
|
|
96
|
+
isStopped: () => this.stopped,
|
|
97
|
+
sendOrEditStreamMessage: (text) => this.pushStreamChunk(text),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Send an informative status update (blue progress bar in Teams).
|
|
103
|
+
* Call this immediately when a message is received, before LLM starts generating.
|
|
104
|
+
* Establishes the stream so subsequent chunks continue from this stream ID.
|
|
105
|
+
*/
|
|
106
|
+
async sendInformativeUpdate(text: string): Promise<void> {
|
|
107
|
+
if (this.stopped || this.finalized) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
this.sequenceNumber++;
|
|
112
|
+
|
|
113
|
+
const activity: Record<string, unknown> = {
|
|
114
|
+
type: "typing",
|
|
115
|
+
text,
|
|
116
|
+
entities: [buildStreamInfoEntity(this.streamId, "informative", this.sequenceNumber)],
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
const response = await this.sendActivity(activity);
|
|
121
|
+
if (!this.streamId) {
|
|
122
|
+
this.streamId = extractId(response);
|
|
123
|
+
}
|
|
124
|
+
} catch (err) {
|
|
125
|
+
this.onError?.(err);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Ingest partial text from the LLM token stream.
|
|
131
|
+
* Called by onPartialReply — accumulates text and throttles updates.
|
|
132
|
+
*/
|
|
133
|
+
update(text: string): void {
|
|
134
|
+
if (this.stopped || this.finalized) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
this.accumulatedText = text;
|
|
138
|
+
|
|
139
|
+
// Wait for minimum chars before first send (avoids push notification flicker)
|
|
140
|
+
if (!this.streamId && this.accumulatedText.length < MIN_INITIAL_CHARS) {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Text exceeded Teams limit — finalize immediately with what we have
|
|
145
|
+
// so the user isn't left waiting while the LLM keeps generating.
|
|
146
|
+
if (this.accumulatedText.length > TEAMS_MAX_CHARS) {
|
|
147
|
+
this.streamFailed = true;
|
|
148
|
+
void this.finalize();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Stop early before Teams expires the stream server-side. finalize() will
|
|
153
|
+
// close the stream with the last good content, and reply-stream-controller
|
|
154
|
+
// will deliver any remaining suffix via normal fallback delivery.
|
|
155
|
+
if (this.streamStartedAt && Date.now() - this.streamStartedAt >= MAX_STREAM_AGE_MS) {
|
|
156
|
+
this.streamFailed = true;
|
|
157
|
+
void this.finalize();
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Don't append cursor — Teams requires each chunk to be a prefix of subsequent chunks.
|
|
162
|
+
// The cursor character would cause "content should contain previously streamed content" errors.
|
|
163
|
+
this.loop.update(this.accumulatedText);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Finalize the stream — send the final message activity.
|
|
168
|
+
*/
|
|
169
|
+
async finalize(): Promise<void> {
|
|
170
|
+
if (this.finalized) {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
this.finalized = true;
|
|
174
|
+
this.stopped = true;
|
|
175
|
+
this.loop.stop();
|
|
176
|
+
await this.loop.waitForInFlight();
|
|
177
|
+
|
|
178
|
+
// If no text was streamed (e.g. agent sent a card via tool instead of
|
|
179
|
+
// streaming text), just return. Teams auto-clears the informative progress
|
|
180
|
+
// bar after its streaming timeout. Sending an empty final message fails
|
|
181
|
+
// with 403.
|
|
182
|
+
if (!this.accumulatedText.trim()) {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// If streaming failed (>4000 chars or POST errors), close the stream
|
|
187
|
+
// with the last successfully streamed text so Teams removes the "Stop"
|
|
188
|
+
// button and replaces the partial chunks. deliver() handles the complete
|
|
189
|
+
// response since hasContent returns false when streamFailed is true.
|
|
190
|
+
if (this.streamFailed) {
|
|
191
|
+
if (this.streamId) {
|
|
192
|
+
try {
|
|
193
|
+
await this.sendActivity({
|
|
194
|
+
type: "message",
|
|
195
|
+
text: this.lastStreamedText || "",
|
|
196
|
+
channelData: { feedbackLoopEnabled: this.feedbackLoopEnabled },
|
|
197
|
+
entities: [AI_GENERATED_ENTITY, buildStreamInfoEntity(this.streamId, "final")],
|
|
198
|
+
});
|
|
199
|
+
} catch {
|
|
200
|
+
// Best effort — stream will auto-close after Teams timeout
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Send final message activity.
|
|
207
|
+
// Per the spec: type=message, streamType=final, NO streamSequence.
|
|
208
|
+
try {
|
|
209
|
+
const entities: Array<Record<string, unknown>> = [AI_GENERATED_ENTITY];
|
|
210
|
+
if (this.streamId) {
|
|
211
|
+
entities.push(buildStreamInfoEntity(this.streamId, "final"));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const finalActivity: Record<string, unknown> = {
|
|
215
|
+
type: "message",
|
|
216
|
+
text: this.accumulatedText,
|
|
217
|
+
channelData: {
|
|
218
|
+
feedbackLoopEnabled: this.feedbackLoopEnabled,
|
|
219
|
+
},
|
|
220
|
+
entities,
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
await this.sendActivity(finalActivity);
|
|
224
|
+
} catch (err) {
|
|
225
|
+
this.onError?.(err);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Whether streaming successfully delivered content (at least one chunk sent, not failed). */
|
|
230
|
+
get hasContent(): boolean {
|
|
231
|
+
return this.accumulatedText.length > 0 && !this.streamFailed;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Whether streaming failed and fallback delivery is needed. */
|
|
235
|
+
get isFailed(): boolean {
|
|
236
|
+
return this.streamFailed;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Number of characters successfully streamed before failure. */
|
|
240
|
+
get streamedLength(): number {
|
|
241
|
+
return this.lastStreamedText.length;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Whether the stream has been finalized. */
|
|
245
|
+
get isFinalized(): boolean {
|
|
246
|
+
return this.finalized;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Whether streaming fell back (not used in this implementation). */
|
|
250
|
+
get isFallback(): boolean {
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Send a single streaming chunk as a typing activity with streaminfo.
|
|
256
|
+
* Per the Teams REST API spec:
|
|
257
|
+
* - First chunk: no streamId, streamSequence=1 → returns 201 with { id: streamId }
|
|
258
|
+
* - Subsequent chunks: include streamId, increment streamSequence → returns 202
|
|
259
|
+
*/
|
|
260
|
+
private async pushStreamChunk(text: string): Promise<boolean> {
|
|
261
|
+
if (this.stopped && !this.finalized) {
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
this.sequenceNumber++;
|
|
266
|
+
|
|
267
|
+
const activity: Record<string, unknown> = {
|
|
268
|
+
type: "typing",
|
|
269
|
+
text,
|
|
270
|
+
entities: [buildStreamInfoEntity(this.streamId, "streaming", this.sequenceNumber)],
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
try {
|
|
274
|
+
const response = await this.sendActivity(activity);
|
|
275
|
+
if (!this.streamStartedAt) {
|
|
276
|
+
this.streamStartedAt = Date.now();
|
|
277
|
+
}
|
|
278
|
+
if (!this.streamId) {
|
|
279
|
+
this.streamId = extractId(response);
|
|
280
|
+
}
|
|
281
|
+
this.lastStreamedText = text;
|
|
282
|
+
return true;
|
|
283
|
+
} catch (err) {
|
|
284
|
+
const axiosData = (err as { response?: { data?: unknown; status?: number } })?.response;
|
|
285
|
+
const statusCode = axiosData?.status ?? (err as { statusCode?: number })?.statusCode;
|
|
286
|
+
const responseBody = axiosData?.data ? JSON.stringify(axiosData.data).slice(0, 300) : "";
|
|
287
|
+
const msg = formatUnknownError(err);
|
|
288
|
+
this.onError?.(
|
|
289
|
+
new Error(
|
|
290
|
+
`stream POST failed (HTTP ${statusCode ?? "?"}): ${msg}${responseBody ? ` body=${responseBody}` : ""}`,
|
|
291
|
+
),
|
|
292
|
+
);
|
|
293
|
+
this.streamFailed = true;
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
package/src/test-runtime.ts
CHANGED