@hahnfeld/teams-adapter 1.4.1 → 1.5.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.
@@ -1,30 +1,93 @@
1
1
  import { log } from "@openacp/plugin-sdk";
2
2
  import { CardFactory } from "@microsoft/agents-hosting";
3
+ // ─── Constants ────────────────────────────────────────────────────────────────
3
4
  const MAX_ROOT_TEXT_LENGTH = 25_000;
4
- /** How long to wait with no activity before warning about truncation. */
5
5
  const STALL_TIMEOUT = 120_000;
6
- // ─── Brand Colors ────────────────────────────────────────────────────────────
7
- // Primary blue #01426a | Dark blue #00274a | Purple #463c8f
8
- // Cyan #5de3f7 | Green #a3cd6a | Magenta #ce0c88
9
- // Text default #2a2a2a | Muted #676767 | White #ffffff
10
- const BRAND = {
11
- blue: "#01426a",
12
- blueDark: "#00274a",
13
- purple: "#463c8f",
14
- cyan: "#5de3f7",
15
- green: "#a3cd6a",
16
- magenta: "#ce0c88",
17
- textDefault: "#2a2a2a",
18
- textMuted: "#676767",
19
- white: "#ffffff",
20
- surfaceMuted: "#f7f7f7",
21
- };
22
- // ─── ID generation ─────────────────────────────────────────────────────────────
6
+ // ─── ID generation ────────────────────────────────────────────────────────────
23
7
  let _idCounter = 0;
24
8
  function nextId() {
25
9
  return `e${++_idCounter}_${Date.now().toString(36)}`;
26
10
  }
11
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
12
+ function formatElapsed(ms) {
13
+ if (ms < 1000)
14
+ return `${ms}ms`;
15
+ if (ms < 60_000)
16
+ return `${(ms / 1000).toFixed(1)}s`;
17
+ return `${Math.floor(ms / 60_000)}m ${Math.floor((ms % 60_000) / 1000)}s`;
18
+ }
19
+ export function escapeMd(text) {
20
+ return text.replace(/\[/g, "\\[").replace(/\*/g, "\\*").replace(/\]/g, "\\]");
21
+ }
22
+ const PLAN_STATUS_ICONS = {
23
+ completed: "✅",
24
+ in_progress: "🔄",
25
+ pending: "⏳",
26
+ };
27
27
  // ─── Adaptive Card Builder ────────────────────────────────────────────────────
28
+ /**
29
+ * Build a 2-column ColumnSet for level-1 headings.
30
+ * Column 1: emoji (auto), Column 2: text (stretch, wrap).
31
+ * Ensures long text wraps without breaking back to the left margin.
32
+ */
33
+ export function buildLevel1(emoji, text) {
34
+ return {
35
+ type: "ColumnSet",
36
+ spacing: "None",
37
+ columns: [
38
+ {
39
+ type: "Column",
40
+ width: "auto",
41
+ items: [{ type: "TextBlock", text: emoji, size: "Small", fontType: "Monospace", spacing: "None" }],
42
+ verticalContentAlignment: "Top",
43
+ },
44
+ {
45
+ type: "Column",
46
+ width: "stretch",
47
+ items: [{ type: "TextBlock", text, size: "Small", fontType: "Monospace", wrap: true, spacing: "None" }],
48
+ },
49
+ ],
50
+ };
51
+ }
52
+ /**
53
+ * Build a 3-column ColumnSet for indented level-2 content.
54
+ * Column 1: spacer (20px), Column 2: ⎿ (auto), Column 3: content (stretch, wrap).
55
+ */
56
+ export function buildLevel2(text, elapsed, raw = false) {
57
+ const escaped = raw ? text : escapeMd(text);
58
+ const content = elapsed ? `${escaped} (${elapsed})` : escaped;
59
+ return {
60
+ type: "ColumnSet",
61
+ spacing: "None",
62
+ columns: [
63
+ { type: "Column", width: "20px" },
64
+ {
65
+ type: "Column",
66
+ width: "auto",
67
+ items: [{
68
+ type: "TextBlock",
69
+ text: "⎿",
70
+ size: "Small",
71
+ fontType: "Monospace",
72
+ spacing: "None",
73
+ }],
74
+ verticalContentAlignment: "Top",
75
+ },
76
+ {
77
+ type: "Column",
78
+ width: "stretch",
79
+ items: [{
80
+ type: "TextBlock",
81
+ text: content,
82
+ size: "Small",
83
+ fontType: "Monospace",
84
+ wrap: true,
85
+ spacing: "None",
86
+ }],
87
+ },
88
+ ],
89
+ };
90
+ }
28
91
  function buildCardBody(entries) {
29
92
  const blocks = [];
30
93
  const now = Date.now();
@@ -40,45 +103,28 @@ function buildCardBody(entries) {
40
103
  spacing: "None",
41
104
  });
42
105
  break;
43
- case "tool": {
106
+ case "timed": {
44
107
  const elapsed = entry.result
45
108
  ? formatElapsed((entry.endedAt ?? entry.startedAt) - entry.startedAt)
46
109
  : formatElapsed(now - entry.startedAt);
110
+ const headingText = entry.result
111
+ ? escapeMd(entry.label)
112
+ : `${escapeMd(entry.label)}… (${elapsed})`;
113
+ const items = [buildLevel1(entry.emoji, headingText)];
114
+ if (entry.result) {
115
+ items.push(buildLevel2(entry.result, elapsed));
116
+ }
117
+ blocks.push({ type: "Container", items, spacing: "Small" });
118
+ break;
119
+ }
120
+ case "info": {
47
121
  blocks.push({
48
122
  type: "Container",
49
123
  items: [
50
- {
51
- type: "TextBlock",
52
- text: entry.result
53
- ? `🔧 ${escapeMd(entry.toolName)}`
54
- : `🔧 ${escapeMd(entry.toolName)}… (${elapsed})`,
55
- size: "Small",
56
- fontType: "Monospace",
57
- spacing: "None",
58
- },
59
- // Result: indented with L-shaped bar
60
- ...(entry.result
61
- ? [{
62
- type: "TextBlock",
63
- text: `\u00A0\u00A0⎿ ${escapeMd(entry.result)} (${elapsed})`,
64
- size: "Small",
65
- fontType: "Monospace",
66
- wrap: true,
67
- spacing: "None",
68
- }]
69
- : []),
70
- // Children: further indented
71
- ...entry.children.map((c) => ({
72
- type: "TextBlock",
73
- text: `\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0${c.text}`,
74
- size: "Small",
75
- fontType: "Monospace",
76
- wrap: true,
77
- spacing: "None",
78
- })),
124
+ buildLevel1(entry.emoji, escapeMd(entry.label)),
125
+ buildLevel2(entry.detail),
79
126
  ],
80
- padding: "Small",
81
- width: "stretch",
127
+ spacing: "Small",
82
128
  });
83
129
  break;
84
130
  }
@@ -92,11 +138,22 @@ function buildCardBody(entries) {
92
138
  spacing: "None",
93
139
  });
94
140
  break;
95
- case "thought":
141
+ case "plan": {
142
+ const lines = entry.entries.map((e, i) => `${PLAN_STATUS_ICONS[e.status] || "⏳"} ${i + 1}. ${escapeMd(e.content)}`);
143
+ blocks.push({
144
+ type: "TextBlock",
145
+ text: `📋 Plan\n${lines.join("\n")}`,
146
+ size: "Small",
147
+ fontType: "Monospace",
148
+ wrap: true,
149
+ spacing: "Small",
150
+ });
151
+ break;
152
+ }
153
+ case "resource":
96
154
  blocks.push({
97
155
  type: "TextBlock",
98
156
  text: entry.text,
99
- italic: true,
100
157
  size: "Small",
101
158
  fontType: "Monospace",
102
159
  wrap: true,
@@ -107,7 +164,7 @@ function buildCardBody(entries) {
107
164
  blocks.push({
108
165
  type: "TextBlock",
109
166
  text: `*${escapeMd(entry.text)}*`,
110
- italic: true,
167
+ isSubtle: true,
111
168
  size: "Small",
112
169
  fontType: "Monospace",
113
170
  spacing: "None",
@@ -126,17 +183,9 @@ function buildCardBody(entries) {
126
183
  }
127
184
  return blocks;
128
185
  }
129
- function formatElapsed(ms) {
130
- if (ms < 1000)
131
- return `${ms}ms`;
132
- if (ms < 60_000)
133
- return `${(ms / 1000).toFixed(1)}s`;
134
- return `${Math.floor(ms / 60_000)}m ${Math.floor((ms % 60_000) / 1000)}s`;
135
- }
136
- function escapeMd(text) {
137
- return text.replace(/\[/g, "\\[").replace(/\*/g, "\\*").replace(/\]/g, "\\]");
138
- }
139
- // ─── SessionMessage ────────────────────────────────────────────────────────────
186
+ // ─── SessionMessage ───────────────────────────────────────────────────────────
187
+ /** Dot animation frames: Working. → Working.. → Working... → Working.. → repeat */
188
+ const WORKING_FRAMES = ["Working.", "Working..", "Working...", "Working.."];
140
189
  export class SessionMessage {
141
190
  context;
142
191
  conversationId;
@@ -146,19 +195,31 @@ export class SessionMessage {
146
195
  entries = [];
147
196
  titleId = null;
148
197
  usageId = null;
149
- /** The active tool whose children accumulate streaming output */
150
- toolActive = null;
198
+ planId = null;
199
+ thinkingActive = null;
200
+ thinkingText = "";
201
+ working = true;
202
+ workingFrame = 0;
203
+ destroyed = false;
151
204
  ref = null;
152
205
  lastSent = "";
153
206
  stallTimer;
154
- /** Interval handle for periodic elapsed-time updates on running tools */
155
207
  tickInterval;
208
+ emptyCardTimer;
156
209
  constructor(context, conversationId, sessionId, rateLimiter, acquireBotToken) {
157
210
  this.context = context;
158
211
  this.conversationId = conversationId;
159
212
  this.sessionId = sessionId;
160
213
  this.rateLimiter = rateLimiter;
161
214
  this.acquireBotToken = acquireBotToken;
215
+ // Start the working animation immediately
216
+ this.usageId = nextId();
217
+ this.entries.push({ id: this.usageId, kind: "usage", text: WORKING_FRAMES[0] });
218
+ this.startTickInterval();
219
+ // If no real content arrives within 30s, delete the empty card
220
+ this.emptyCardTimer = setTimeout(() => this.deleteIfEmpty(), 30_000);
221
+ if (this.emptyCardTimer.unref)
222
+ this.emptyCardTimer.unref();
162
223
  }
163
224
  updateContext(context) {
164
225
  this.context = context;
@@ -176,9 +237,46 @@ export class SessionMessage {
176
237
  const usage = this.entries.find((e) => e.kind === "usage");
177
238
  return usage ? usage.text : null;
178
239
  }
179
- // ─── Entry API ───────────────────────────────────────────────────────────
180
- /** Set the persistent session title (bold, survives finalize). */
240
+ // ─── Empty card cleanup ──────────────────────────────────────────────────
241
+ /** Cancel the empty-card timer (called when real content arrives). */
242
+ cancelEmptyCardTimer() {
243
+ if (this.emptyCardTimer) {
244
+ clearTimeout(this.emptyCardTimer);
245
+ this.emptyCardTimer = undefined;
246
+ }
247
+ }
248
+ /** If no real content was added, delete the card activity and clean up. */
249
+ async deleteIfEmpty() {
250
+ this.emptyCardTimer = undefined;
251
+ if (this.entries.some((e) => e.kind !== "usage"))
252
+ return;
253
+ this.destroyed = true;
254
+ // Delete the card activity if it was sent
255
+ if (this.ref) {
256
+ const token = await this.acquireBotToken();
257
+ // Re-check after await — content may have arrived while token was fetched
258
+ if (this.entries.some((e) => e.kind !== "usage")) {
259
+ this.destroyed = false;
260
+ return;
261
+ }
262
+ if (token) {
263
+ const url = `${this.ref.serviceUrl}/v3/conversations/${encodeURIComponent(this.ref.conversationId)}/activities/${encodeURIComponent(this.ref.activityId)}`;
264
+ try {
265
+ await fetch(url, { method: "DELETE", headers: { "Authorization": `Bearer ${token}` } });
266
+ }
267
+ catch { /* best effort */ }
268
+ }
269
+ this.ref = null;
270
+ }
271
+ this.stopTickInterval();
272
+ this.working = false;
273
+ this.entries = [];
274
+ log.debug({ sessionId: this.sessionId }, "[SessionMessage] Empty card deleted after timeout");
275
+ }
276
+ // ─── Entry API ──────────────────────────────────────────────────────────
277
+ /** Set the persistent session title (bold, always first entry). */
181
278
  setTitle(text) {
279
+ this.cancelEmptyCardTimer();
182
280
  if (this.titleId) {
183
281
  const entry = this.findEntry(this.titleId);
184
282
  if (entry && entry.kind === "title")
@@ -191,59 +289,94 @@ export class SessionMessage {
191
289
  this.requestFlush();
192
290
  }
193
291
  /**
194
- * Add a tool-progress entry (🔄 Running...). Sets toolActive so subsequent
195
- * addText() calls route children here. Returns entry id for tracking.
292
+ * Start a timed entry (tool or thinking). Shows emoji + label with live elapsed timer.
293
+ * Returns the entry ID for pairing with addTimedResult().
196
294
  */
197
- addToolStart(toolName, _params) {
295
+ addTimedStart(emoji, label) {
296
+ this.cancelEmptyCardTimer();
198
297
  const id = nextId();
199
- const startedAt = Date.now();
200
- this.entries.push({ id, kind: "tool", toolName, startedAt, children: [] });
201
- this.toolActive = id;
298
+ this.entries.push({ id, kind: "timed", emoji, label, startedAt: Date.now() });
202
299
  this.resetStallTimer();
203
300
  this.startTickInterval();
204
301
  this.requestFlush();
205
302
  return id;
206
303
  }
207
304
  /**
208
- * Append result to the tool entry (transforms it from running to complete).
209
- * Subsequent text goes to the same entry's children.
305
+ * Close a timed entry by setting its result. Stops the tick interval if no
306
+ * other timed entries are still running.
210
307
  */
211
- addToolResult(id, result) {
212
- const entry = this.entries.find((e) => e.id === id);
213
- if (!entry || entry.kind !== "tool") {
214
- // Entry not found — create a standalone tool entry
215
- const startedAt = Date.now();
216
- this.entries.push({ id: nextId(), kind: "tool", toolName: "", startedAt, result, endedAt: startedAt, children: [] });
217
- this.toolActive = null;
308
+ addTimedResult(id, result) {
309
+ const entry = this.findEntry(id);
310
+ if (entry && entry.kind === "timed") {
311
+ entry.result = result;
312
+ entry.endedAt = Date.now();
313
+ }
314
+ else {
315
+ // Orphan result — create a standalone completed entry
316
+ this.entries.push({ id: nextId(), kind: "timed", emoji: "🔧", label: result, startedAt: Date.now(), result, endedAt: Date.now() });
317
+ }
318
+ const hasRunning = this.entries.some((e) => e.kind === "timed" && !e.result);
319
+ if (!hasRunning)
218
320
  this.stopTickInterval();
219
- this.requestFlush();
220
- return;
321
+ this.requestFlush();
322
+ }
323
+ /**
324
+ * Start or accumulate thinking text. The first call creates a timed entry;
325
+ * subsequent calls append to the pending result text. Call closeActiveThinking()
326
+ * to finalize.
327
+ */
328
+ addThinking(text) {
329
+ this.cancelEmptyCardTimer();
330
+ if (!this.thinkingActive) {
331
+ const id = this.addTimedStart("☁️", "Thinking");
332
+ this.thinkingActive = id;
333
+ this.thinkingText = text;
334
+ }
335
+ else {
336
+ this.thinkingText += ` ${text}`;
221
337
  }
222
- entry.result = result;
223
- entry.endedAt = Date.now();
224
- this.toolActive = null; // Subsequent text goes to root level
225
- this.stopTickInterval();
226
338
  this.requestFlush();
227
339
  }
228
- /** Add text — goes to toolActive children if a tool is running, else root text entry. */
229
- addText(text) {
230
- if (!text)
340
+ /**
341
+ * Close the active thinking entry (if any). Called by non-thought handlers
342
+ * so thinking ends when the next event type arrives.
343
+ */
344
+ closeActiveThinking() {
345
+ if (!this.thinkingActive)
231
346
  return;
232
- if (this.toolActive) {
233
- const entry = this.findEntry(this.toolActive);
234
- if (entry && entry.kind === "tool") {
235
- entry.children.push({ text });
236
- this.resetStallTimer();
347
+ const text = this.thinkingText.trim() || "…";
348
+ this.addTimedResult(this.thinkingActive, text);
349
+ this.thinkingActive = null;
350
+ this.thinkingText = "";
351
+ }
352
+ /** Add a one-shot info entry (error, system, mode, config, model). */
353
+ addInfo(emoji, label, detail) {
354
+ this.cancelEmptyCardTimer();
355
+ this.entries.push({ id: nextId(), kind: "info", emoji, label, detail });
356
+ this.requestFlush();
357
+ }
358
+ /** Add or replace the plan entry. */
359
+ setPlan(entries) {
360
+ this.cancelEmptyCardTimer();
361
+ if (this.planId) {
362
+ const entry = this.findEntry(this.planId);
363
+ if (entry && entry.kind === "plan") {
364
+ entry.entries = entries;
237
365
  this.requestFlush();
238
- // Check for overflow (root text limit)
239
- if (this.ref)
240
- this.checkSplit();
241
366
  return;
242
367
  }
243
368
  }
244
- // Root-level text — append to last root text entry or create new
369
+ this.planId = nextId();
370
+ this.entries.push({ id: this.planId, kind: "plan", entries });
371
+ this.requestFlush();
372
+ }
373
+ /** Add text — always appended at root level. */
374
+ addText(text) {
375
+ if (!text)
376
+ return;
377
+ this.cancelEmptyCardTimer();
245
378
  const lastText = this.entries.filter((e) => e.kind === "text").at(-1);
246
- if (lastText) {
379
+ if (lastText && lastText.kind === "text") {
247
380
  lastText.text += text;
248
381
  }
249
382
  else {
@@ -251,29 +384,18 @@ export class SessionMessage {
251
384
  }
252
385
  this.resetStallTimer();
253
386
  this.requestFlush();
254
- // Check for overflow after root text grows
255
387
  if (this.ref)
256
388
  this.checkSplit();
257
389
  }
258
- /**
259
- * Append text to the last thought entry, or create a new one if none exists.
260
- * If text starts with "Thinking..." and it's a fresh entry, wrap with newlines.
261
- * Subsequent chunks are appended as plain text with no bubble prefix.
262
- */
263
- addThought(text) {
264
- const thoughts = this.entries.filter((e) => e.kind === "thought");
265
- const last = thoughts[thoughts.length - 1];
266
- if (last) {
267
- // Add a space between consecutive chunks so they don't run together
268
- last.text += ` ${text}`;
269
- }
270
- else {
271
- this.entries.push({ id: nextId(), kind: "thought", text });
272
- }
390
+ /** Add an inline resource line (📎 prefix). */
391
+ addResource(text) {
392
+ this.cancelEmptyCardTimer();
393
+ this.entries.push({ id: nextId(), kind: "resource", text });
273
394
  this.requestFlush();
274
395
  }
275
- /** Set or replace the usage entry (only one at a time). */
396
+ /** Set or replace the usage footer. Stops the working animation. */
276
397
  setUsage(text) {
398
+ this.working = false;
277
399
  if (this.usageId) {
278
400
  const entry = this.findEntry(this.usageId);
279
401
  if (entry && entry.kind === "usage")
@@ -283,6 +405,9 @@ export class SessionMessage {
283
405
  this.usageId = nextId();
284
406
  this.entries.push({ id: this.usageId, kind: "usage", text });
285
407
  }
408
+ const hasRunning = this.entries.some((e) => e.kind === "timed" && !e.result);
409
+ if (!hasRunning)
410
+ this.stopTickInterval();
286
411
  this.requestFlush();
287
412
  }
288
413
  /** Add a divider entry. */
@@ -290,29 +415,24 @@ export class SessionMessage {
290
415
  this.entries.push({ id: nextId(), kind: "divider" });
291
416
  this.requestFlush();
292
417
  }
293
- // ─── Entry helpers ────────────────────────────────────────────────────────
418
+ // ─── Entry helpers ──────────────────────────────────────────────────────
294
419
  findEntry(id) {
295
- for (const entry of this.entries) {
296
- if (entry.id === id)
297
- return entry;
298
- }
299
- return undefined;
300
- }
301
- removeEntry(id) {
302
- this.entries = this.entries.filter((e) => e.id !== id);
420
+ return this.entries.find((e) => e.id === id);
303
421
  }
304
- // ─── Periodic tick for elapsed time updates ───────────────────────────────
305
- /**
306
- * Start a 1-second interval that updates elapsed time on running tool-progress
307
- * entries. Called when a tool starts, stopped when tool completes or finalize.
308
- */
422
+ // ─── Periodic tick for elapsed time updates ─────────────────────────────
309
423
  startTickInterval() {
310
424
  if (this.tickInterval)
311
425
  return;
312
426
  this.tickInterval = setInterval(() => {
313
- // Only tick if there's an active tool-progress entry
314
- const hasRunningTool = this.entries.some((e) => e.kind === "tool" && !e.result);
315
- if (!hasRunningTool) {
427
+ // Advance working dots animation
428
+ if (this.working && this.usageId) {
429
+ this.workingFrame = (this.workingFrame + 1) % WORKING_FRAMES.length;
430
+ const entry = this.findEntry(this.usageId);
431
+ if (entry && entry.kind === "usage")
432
+ entry.text = WORKING_FRAMES[this.workingFrame];
433
+ }
434
+ const hasRunning = this.entries.some((e) => e.kind === "timed" && !e.result);
435
+ if (!hasRunning && !this.working) {
316
436
  this.stopTickInterval();
317
437
  return;
318
438
  }
@@ -327,27 +447,23 @@ export class SessionMessage {
327
447
  this.tickInterval = undefined;
328
448
  }
329
449
  }
330
- // ─── Card building ─────────────────────────────────────────────────────────
450
+ // ─── Card building ──────────────────────────────────────────────────────
331
451
  buildCard() {
332
452
  const body = buildCardBody(this.entries);
333
453
  return {
334
454
  type: "AdaptiveCard",
335
455
  version: "1.4",
336
- body: [
337
- ...(body.length > 0 ? body : [{ type: "TextBlock", text: "…" }]),
338
- ],
339
- // Use full available width
456
+ body: body.length > 0 ? body : [{ type: "TextBlock", text: "…" }],
340
457
  width: "stretch",
341
458
  };
342
459
  }
343
- // ─── Flush / Rate limiting ─────────────────────────────────────────────────
460
+ // ─── Flush / Rate limiting ──────────────────────────────────────────────
344
461
  flushTimer;
345
462
  requestFlush() {
346
463
  if (this.flushTimer)
347
464
  clearTimeout(this.flushTimer);
348
465
  this.flushTimer = setTimeout(() => {
349
466
  this.flushTimer = undefined;
350
- const card = this.buildCard();
351
467
  const key = this.ref ? `update:${this.ref.activityId}` : `new:${this.sessionId}`;
352
468
  this.rateLimiter.enqueue(this.conversationId, () => this.flush(), key).catch((err) => {
353
469
  log.warn({ err, sessionId: this.sessionId }, "[SessionMessage] flush failed");
@@ -355,6 +471,8 @@ export class SessionMessage {
355
471
  }, 500);
356
472
  }
357
473
  async flush() {
474
+ if (this.destroyed)
475
+ return;
358
476
  const card = this.buildCard();
359
477
  const cardStr = JSON.stringify(card);
360
478
  if (!cardStr || cardStr === this.lastSent)
@@ -372,9 +490,8 @@ export class SessionMessage {
372
490
  }
373
491
  else {
374
492
  const success = await this.updateCardViaRest(card);
375
- if (success) {
493
+ if (success)
376
494
  this.lastSent = cardStr;
377
- }
378
495
  }
379
496
  // Content arrived while flushing — request another flush
380
497
  const current = this.buildCard();
@@ -392,10 +509,7 @@ export class SessionMessage {
392
509
  try {
393
510
  const response = await fetch(url, {
394
511
  method: "PUT",
395
- headers: {
396
- "Content-Type": "application/json",
397
- "Authorization": `Bearer ${token}`,
398
- },
512
+ headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
399
513
  body: JSON.stringify({
400
514
  type: "message",
401
515
  attachments: [CardFactory.adaptiveCard(card)],
@@ -412,13 +526,12 @@ export class SessionMessage {
412
526
  return false;
413
527
  }
414
528
  }
415
- // ─── Stall timer ───────────────────────────────────────────────────────────
529
+ // ─── Stall timer ────────────────────────────────────────────────────────
416
530
  resetStallTimer() {
417
531
  if (this.stallTimer)
418
532
  clearTimeout(this.stallTimer);
419
533
  this.stallTimer = setTimeout(() => {
420
- const hasContent = this.entries.some((e) => (e.kind === "text" && e.text.length > 0) ||
421
- (e.kind === "tool" && e.children.length > 0));
534
+ const hasContent = this.entries.some((e) => e.kind === "text" && e.text.length > 0);
422
535
  const hasUsage = this.entries.some((e) => e.kind === "usage");
423
536
  if (hasContent && !hasUsage) {
424
537
  log.warn({ sessionId: this.sessionId }, "[SessionMessage] Stream stalled — adding cutoff notice");
@@ -434,9 +547,16 @@ export class SessionMessage {
434
547
  if (this.stallTimer.unref)
435
548
  this.stallTimer.unref();
436
549
  }
437
- // ─── Finalize ─────────────────────────────────────────────────────────────
550
+ // ─── Finalize ───────────────────────────────────────────────────────────
438
551
  async finalize() {
552
+ this.cancelEmptyCardTimer();
553
+ this.closeActiveThinking();
554
+ this.working = false;
439
555
  this.stopTickInterval();
556
+ if (this.flushTimer) {
557
+ clearTimeout(this.flushTimer);
558
+ this.flushTimer = undefined;
559
+ }
440
560
  if (this.stallTimer) {
441
561
  clearTimeout(this.stallTimer);
442
562
  this.stallTimer = undefined;
@@ -461,7 +581,7 @@ export class SessionMessage {
461
581
  }
462
582
  return this.ref;
463
583
  }
464
- /** Legacy compat — strip pattern from root text entries. */
584
+ /** Strip a pattern from root text entries. */
465
585
  async stripPattern(pattern) {
466
586
  for (const entry of this.entries) {
467
587
  if (entry.kind === "text") {
@@ -472,10 +592,7 @@ export class SessionMessage {
472
592
  }
473
593
  }
474
594
  }
475
- /**
476
- * Check if root text exceeds limit and split into a new message.
477
- * Called from addText when a message is already sent (this.ref != null).
478
- */
595
+ // ─── Text overflow / split ──────────────────────────────────────────────
479
596
  checkSplit() {
480
597
  const rootText = this.entries
481
598
  .filter((e) => e.kind === "text")
@@ -485,7 +602,6 @@ export class SessionMessage {
485
602
  return;
486
603
  this.split();
487
604
  }
488
- /** For split: finalize current message at limit, start fresh. */
489
605
  split() {
490
606
  const rootText = this.entries
491
607
  .filter((e) => e.kind === "text")
@@ -494,73 +610,51 @@ export class SessionMessage {
494
610
  if (rootText.length <= MAX_ROOT_TEXT_LENGTH)
495
611
  return;
496
612
  let accLen = 0;
497
- const entriesToKeep = [];
498
- const entriesToOverflow = [];
613
+ const keep = [];
614
+ const overflow = [];
499
615
  for (const entry of this.entries) {
500
616
  if (entry.kind === "text") {
501
- const text = entry.text;
502
- if (accLen + text.length <= MAX_ROOT_TEXT_LENGTH) {
503
- entriesToKeep.push(entry);
504
- accLen += text.length;
617
+ if (accLen + entry.text.length <= MAX_ROOT_TEXT_LENGTH) {
618
+ keep.push(entry);
619
+ accLen += entry.text.length;
505
620
  }
506
621
  else {
507
- entriesToOverflow.push(entry);
622
+ overflow.push(entry);
508
623
  }
509
624
  }
510
625
  else {
511
- entriesToKeep.push(entry);
512
- }
513
- }
514
- log.info({ sessionId: this.sessionId, kept: entriesToKeep.length, overflow: entriesToOverflow.length }, "[SessionMessage] splitting");
515
- this.entries = entriesToKeep;
516
- if (entriesToOverflow.length > 0) {
517
- const overflowText = entriesToOverflow
518
- .filter((e) => e.kind === "text")
519
- .map((e) => e.text)
520
- .join("");
521
- if (overflowText) {
522
- this.entries.push({ id: nextId(), kind: "text", text: overflowText });
626
+ keep.push(entry);
523
627
  }
524
628
  }
629
+ log.info({ sessionId: this.sessionId, kept: keep.length, overflow: overflow.length }, "[SessionMessage] splitting");
630
+ const overflowText = overflow
631
+ .filter((e) => e.kind === "text")
632
+ .map((e) => e.text)
633
+ .join("");
634
+ // Update the old card with only the kept entries (no overflow)
635
+ this.entries = keep;
525
636
  if (this.ref) {
526
637
  const card = this.buildCard();
527
638
  this.rateLimiter.enqueue(this.conversationId, () => this.updateCardViaRest(card).then(() => { }), `update:${this.ref.activityId}`).catch(() => { });
528
639
  }
640
+ // Start a new card with only the overflow text — reset all entry ID references
641
+ // so subsequent calls create new entries in the new card
529
642
  this.ref = null;
530
643
  this.lastSent = "";
644
+ this.titleId = null;
645
+ this.usageId = null;
646
+ this.planId = null;
647
+ this.entries = overflowText
648
+ ? [{ id: nextId(), kind: "text", text: overflowText }]
649
+ : [];
531
650
  this.requestFlush();
532
651
  }
533
- // ─── Legacy API (no-ops for compat) ────────────────────────────────────────
534
- /** @deprecated — use addThought instead */
535
- setHeader(_text) { }
536
- /** @deprecated — use addToolResult instead */
537
- setHeaderResult(_text) { }
538
- /** @deprecated — use addText instead */
539
- appendBody(text) {
540
- this.addText(text);
541
- }
542
- /** @deprecated — use setUsage instead */
543
- setFooter(text) {
544
- this.setUsage(text);
545
- }
546
- /** @deprecated — use setUsage instead */
547
- appendFooter(text) {
548
- const current = this.getFooter();
549
- this.setUsage(current ? `${current} · ${text}` : text);
550
- }
551
- /** @deprecated — header zone is gone */
552
- clearHeader() { }
553
- /** @deprecated — thoughts persist, use addThought */
554
- removeThought() { }
555
- /** @deprecated — use addToolStart + addToolResult */
556
- updateToolResult(_id, _result) { }
557
652
  }
558
- // ─── SessionMessageManager ─────────────────────────────────────────────────────
653
+ // ─── SessionMessageManager ────────────────────────────────────────────────────
559
654
  export class SessionMessageManager {
560
655
  rateLimiter;
561
656
  acquireBotToken;
562
657
  messages = new Map();
563
- planRefs = new Map();
564
658
  constructor(rateLimiter, acquireBotToken) {
565
659
  this.rateLimiter = rateLimiter;
566
660
  this.acquireBotToken = acquireBotToken;
@@ -588,25 +682,16 @@ export class SessionMessageManager {
588
682
  if (!msg)
589
683
  return null;
590
684
  this.messages.delete(sessionId);
591
- this.planRefs.delete(sessionId);
592
685
  return msg.finalize();
593
686
  }
594
- getPlanRef(sessionId) {
595
- return this.planRefs.get(sessionId);
596
- }
597
- setPlanRef(sessionId, ref) {
598
- this.planRefs.set(sessionId, ref);
599
- }
600
687
  cleanup(sessionId) {
601
688
  const msg = this.messages.get(sessionId);
602
- if (msg) {
689
+ if (msg)
603
690
  msg.finalize().catch(() => { });
604
- }
605
691
  this.messages.delete(sessionId);
606
- this.planRefs.delete(sessionId);
607
692
  }
608
693
  }
609
- // ─── Helpers ───────────────────────────────────────────────────────────────────
694
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
610
695
  async function sendCard(context, card) {
611
696
  const activity = {
612
697
  type: "message",