@hahnfeld/teams-adapter 1.2.0 → 1.3.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/dist/adapter.js CHANGED
@@ -1,27 +1,27 @@
1
1
  import { App } from "@microsoft/teams.apps";
2
2
  import { BotBuilderPlugin } from "@microsoft/teams.botbuilder";
3
- import { CardFactory, MemoryStorage } from "@microsoft/agents-hosting";
3
+ import { MemoryStorage } from "@microsoft/agents-hosting";
4
4
  import { CloudAdapter, ConfigurationBotFrameworkAuthentication, } from "botbuilder";
5
5
  import { PasswordServiceClientCredentialFactory } from "botframework-connector";
6
- import { log, MessagingAdapter, SendQueue } from "@openacp/plugin-sdk";
7
- import { TeamsRenderer } from "./renderer.js";
6
+ import { log, MessagingAdapter, BaseRenderer } from "@openacp/plugin-sdk";
8
7
  import { DEFAULT_BOT_PORT } from "./types.js";
9
- import { TeamsDraftManager } from "./draft-manager.js";
8
+ import { SessionMessageManager } from "./message-composer.js";
9
+ import { ConversationRateLimiter } from "./rate-limiter.js";
10
10
  import { PermissionHandler } from "./permissions.js";
11
11
  import { handleCommand, setupCardActionCallbacks } from "./commands/index.js";
12
12
  import { spawnAssistant } from "./assistant.js";
13
- import { downloadTeamsFile, isAttachmentTooLarge, buildFileAttachmentCard, uploadFileViaGraph } from "./media.js";
13
+ import { downloadTeamsFile, isAttachmentTooLarge, uploadFileViaGraph } from "./media.js";
14
14
  import { GraphFileClient } from "./graph.js";
15
15
  import { ConversationStore } from "./conversation-store.js";
16
16
  import { sendText, sendCard, sendActivity } from "./send-utils.js";
17
- import { renderToolCallCard, renderPlanCard, buildCitationEntities, formatTokens } from "./formatting.js";
17
+ import { formatTokens, formatToolSummary, formatPlan } from "./formatting.js";
18
18
  /** Max retry attempts for transient Teams API failures */
19
19
  const MAX_RETRIES = 3;
20
20
  /** Base delay (ms) for exponential backoff */
21
21
  const BASE_RETRY_DELAY = 1000;
22
22
  export class TeamsAdapter extends MessagingAdapter {
23
23
  name = "teams";
24
- renderer = new TeamsRenderer();
24
+ renderer = new BaseRenderer();
25
25
  capabilities = {
26
26
  streaming: true,
27
27
  richFormatting: true,
@@ -33,8 +33,8 @@ export class TeamsAdapter extends MessagingAdapter {
33
33
  core;
34
34
  app;
35
35
  teamsConfig;
36
- sendQueue;
37
- draftManager;
36
+ rateLimiter;
37
+ composer;
38
38
  permissionHandler;
39
39
  notificationChannelId;
40
40
  assistantSession = null;
@@ -48,16 +48,6 @@ export class TeamsAdapter extends MessagingAdapter {
48
48
  */
49
49
  _sessionContexts = new Map();
50
50
  _sessionOutputModes = new Map();
51
- /**
52
- * Per-session serial dispatch queues — matches Telegram's _dispatchQueues pattern.
53
- * SessionBridge fires sendMessage() as fire-and-forget, so multiple events can arrive
54
- * concurrently. Without serialization, fast handlers overtake slow ones, causing
55
- * out-of-order delivery. This queue ensures events are processed in arrival order.
56
- *
57
- * Entries are replaced with Promise.resolve() once their chain settles, preventing
58
- * unbounded closure growth for long-lived sessions.
59
- */
60
- _dispatchQueues = new Map();
61
51
  /** Track processed activity IDs to handle Teams 15-second retry deduplication */
62
52
  _processedActivities = new Map();
63
53
  _processedCleanupTimer;
@@ -74,8 +64,8 @@ export class TeamsAdapter extends MessagingAdapter {
74
64
  { ...config, maxMessageLength: 25000, enabled: config.enabled ?? true });
75
65
  this.core = core;
76
66
  this.teamsConfig = config;
77
- this.sendQueue = new SendQueue({ minInterval: 1000 });
78
- this.draftManager = new TeamsDraftManager(this.sendQueue, () => this.acquireBotToken());
67
+ this.rateLimiter = new ConversationRateLimiter();
68
+ this.composer = new SessionMessageManager(this.rateLimiter, () => this.acquireBotToken());
79
69
  this.fileService = core.fileService;
80
70
  // Persistent conversation reference store for proactive messaging
81
71
  const storageDir = core.configManager.instanceRoot ?? process.cwd();
@@ -169,9 +159,8 @@ export class TeamsAdapter extends MessagingAdapter {
169
159
  }
170
160
  this._sessionContexts.clear();
171
161
  this._sessionOutputModes.clear();
172
- this._dispatchQueues.clear();
173
162
  this._processedActivities.clear();
174
- this.sendQueue.clear();
163
+ this.rateLimiter.destroy();
175
164
  this.conversationStore.destroy();
176
165
  this.permissionHandler.dispose();
177
166
  await this.app.stop();
@@ -348,16 +337,7 @@ export class TeamsAdapter extends MessagingAdapter {
348
337
  return;
349
338
  }
350
339
  if (sessionId !== "unknown") {
351
- // Drain pending dispatches with a timeout — don't block forever if a
352
- // previous prompt hung (e.g., output token exhaustion).
353
- const pendingDispatch = this._dispatchQueues.get(sessionId);
354
- if (pendingDispatch) {
355
- await Promise.race([
356
- pendingDispatch,
357
- new Promise(resolve => setTimeout(resolve, 5000)),
358
- ]);
359
- }
360
- this.draftManager.cleanup(sessionId);
340
+ this.composer.cleanup(sessionId);
361
341
  }
362
342
  // Show typing indicator while the agent processes the message
363
343
  this.sendTyping(context);
@@ -967,13 +947,8 @@ export class TeamsAdapter extends MessagingAdapter {
967
947
  /**
968
948
  * Primary outbound dispatch — routes agent messages to Teams.
969
949
  *
970
- * Wraps the base class `sendMessage` in a per-session promise chain (_dispatchQueues)
971
- * so concurrent events fired from SessionBridge are serialized and delivered in order,
972
- * preventing fast handlers from overtaking slower ones (matches Telegram pattern).
973
- *
974
- * Context is NOT deleted after dispatch — it persists from the inbound message handler
975
- * and is available for the entire session lifetime, avoiding the race condition where
976
- * async handlers lose their context mid-execution.
950
+ * Routes agent messages to Teams. The rate limiter handles per-conversation
951
+ * serialization and throttling.
977
952
  */
978
953
  async sendMessage(sessionId, content) {
979
954
  // Buffer messages during assistant initialization instead of dropping them
@@ -1030,184 +1005,134 @@ export class TeamsAdapter extends MessagingAdapter {
1030
1005
  }
1031
1006
  return;
1032
1007
  }
1033
- // Serialize dispatch per session to preserve event ordering.
1034
- // Read + write the queue entry atomically (synchronous) so concurrent callers
1035
- // always chain on the latest promise, preventing parallel execution.
1036
- const prev = this._dispatchQueues.get(sessionId) ?? Promise.resolve();
1037
- const next = prev.then(async () => {
1038
- try {
1039
- await super.sendMessage(sessionId, content);
1040
- }
1041
- catch (err) {
1042
- log.warn({ err, sessionId, type: content.type }, "[TeamsAdapter] Dispatch error");
1043
- }
1044
- });
1045
- // Set immediately — before any await — so the next concurrent caller sees this entry
1046
- this._dispatchQueues.set(sessionId, next);
1047
- await next;
1048
- // Replace settled chain with a fresh resolved promise to prevent unbounded
1049
- // closure growth for long-lived sessions. Only replace if no new work was
1050
- // chained while we were awaiting (i.e., the entry still points to `next`).
1051
- if (this._dispatchQueues.get(sessionId) === next) {
1052
- this._dispatchQueues.set(sessionId, Promise.resolve());
1008
+ try {
1009
+ await super.sendMessage(sessionId, content);
1010
+ }
1011
+ catch (err) {
1012
+ log.warn({ err, sessionId, type: content.type }, "[TeamsAdapter] Dispatch error");
1053
1013
  }
1054
1014
  }
1055
1015
  // ─── Handler overrides ───────────────────────────────────────────────────
1056
- async handleThought(_sessionId, _content, _verbosity) {
1057
- // Thoughts are not sent as messages in Teams
1016
+ async handleThought(sessionId, content, _verbosity) {
1017
+ const ctx = this._sessionContexts.get(sessionId);
1018
+ if (!ctx)
1019
+ return;
1020
+ const msg = this.composer.getOrCreate(sessionId, ctx.context);
1021
+ this.ensureSessionTitle(sessionId, msg);
1022
+ const summary = content.text?.split("\n")[0]?.slice(0, 100) || "Thinking...";
1023
+ msg.setHeader(`💭 ${summary}`);
1058
1024
  }
1059
1025
  async handleText(sessionId, content) {
1060
1026
  const ctx = this._sessionContexts.get(sessionId);
1061
1027
  if (!ctx)
1062
1028
  return;
1063
- const { context } = ctx;
1064
- // Send typing indicator on first text chunk (before draft exists)
1065
- if (!this.draftManager.hasDraft(sessionId)) {
1066
- this.sendTyping(context);
1067
- }
1068
- const draft = this.draftManager.getOrCreate(sessionId, context);
1029
+ if (!this.composer.has(sessionId))
1030
+ this.sendTyping(ctx.context);
1031
+ const msg = this.composer.getOrCreate(sessionId, ctx.context);
1032
+ this.ensureSessionTitle(sessionId, msg);
1069
1033
  if (content.text)
1070
- draft.append(content.text);
1034
+ msg.appendBody(content.text);
1071
1035
  }
1072
- async handleToolCall(sessionId, content, verbosity) {
1036
+ async handleToolCall(sessionId, content, _verbosity) {
1073
1037
  const ctx = this._sessionContexts.get(sessionId);
1074
1038
  if (!ctx)
1075
1039
  return;
1076
- const { context, isAssistant } = ctx;
1077
- this.sendTyping(context);
1078
- await this.draftManager.finalize(sessionId, context, isAssistant);
1079
- try {
1080
- const meta = (content.metadata ?? {});
1081
- const cardData = renderToolCallCard({
1082
- id: meta.id ?? "",
1083
- name: meta.name ?? content.text ?? "Tool",
1084
- kind: meta.kind,
1085
- status: meta.status,
1086
- rawInput: meta.rawInput,
1087
- content: meta.content,
1088
- displaySummary: meta.displaySummary,
1089
- displayTitle: meta.displayTitle,
1090
- displayKind: meta.displayKind,
1091
- viewerLinks: meta.viewerLinks,
1092
- viewerFilePath: meta.viewerFilePath,
1093
- }, verbosity);
1094
- const card = { type: "AdaptiveCard", version: "1.2", ...cardData };
1095
- // Build citation entities for file references (hover popup with source info)
1096
- const citationSources = [];
1097
- const filePath = meta.viewerFilePath;
1098
- const links = meta.viewerLinks;
1099
- if (filePath && links?.file) {
1100
- citationSources.push({ name: filePath.split("/").pop() || filePath, url: links.file, abstract: `Source: ${filePath}` });
1101
- }
1102
- if (filePath && links?.diff) {
1103
- citationSources.push({ name: `${filePath.split("/").pop() || filePath} (diff)`, url: links.diff, abstract: `Changes to ${filePath}` });
1104
- }
1105
- const citationEntities = buildCitationEntities(citationSources);
1106
- // Citations require [N] markers in the text field — Teams ignores citation entities
1107
- // on card-only activities with no text anchor. Add a text field with markers.
1108
- const citationText = citationSources.length > 0
1109
- ? citationSources.map((s, i) => `[${i + 1}]`).join(" ")
1110
- : undefined;
1111
- await this.sendActivityWithRetry(context, {
1112
- ...(citationText ? { text: citationText } : {}),
1113
- attachments: [CardFactory.adaptiveCard(card)],
1114
- ...(citationEntities.length > 0 ? { entities: citationEntities } : {}),
1115
- });
1116
- }
1117
- catch (err) {
1118
- log.error({ err, sessionId }, "[TeamsAdapter] handleToolCall: sendActivity failed");
1119
- }
1040
+ const msg = this.composer.getOrCreate(sessionId, ctx.context);
1041
+ this.ensureSessionTitle(sessionId, msg);
1042
+ const meta = (content.metadata ?? {});
1043
+ const toolName = meta.name || content.text || "Tool";
1044
+ const summary = formatToolSummary(toolName, meta.rawInput, meta.displaySummary);
1045
+ msg.setHeader(`🔧 ${summary}`);
1120
1046
  }
1121
- async handleToolUpdate(sessionId, content, verbosity) {
1122
- // Only render tool updates in high verbosity mode (matches Telegram's tracker behavior)
1123
- if (verbosity !== "high")
1124
- return;
1047
+ async handleToolUpdate(sessionId, content, _verbosity) {
1125
1048
  const ctx = this._sessionContexts.get(sessionId);
1126
1049
  if (!ctx)
1127
1050
  return;
1128
- const { context } = ctx;
1129
- try {
1130
- const rendered = this.renderer.renderToolUpdate(content, verbosity);
1131
- await this.sendActivityWithRetry(context, { text: rendered.body });
1132
- }
1133
- catch (err) {
1134
- log.warn({ err, sessionId }, "[TeamsAdapter] handleToolUpdate: sendActivity failed");
1135
- }
1051
+ const msg = this.composer.getOrCreate(sessionId, ctx.context);
1052
+ const meta = (content.metadata ?? {});
1053
+ const toolName = meta.name || content.text || "";
1054
+ // Only update header if we have meaningful content — don't overwrite with empty
1055
+ if (!toolName && !meta.displaySummary)
1056
+ return;
1057
+ const summary = formatToolSummary(toolName || "Tool", meta.rawInput, meta.displaySummary);
1058
+ msg.setHeader(`🔧 ${summary}`);
1059
+ }
1060
+ /** Set the session title on the composer if not already set. */
1061
+ ensureSessionTitle(sessionId, msg) {
1062
+ const session = this.core.sessionManager.getSession(sessionId);
1063
+ const name = session?.name;
1064
+ if (name)
1065
+ msg.setTitle(name);
1136
1066
  }
1067
+ /** Per-session plan send mutex to prevent TOCTOU race on first plan message. */
1068
+ _planSending = new Set();
1137
1069
  async handlePlan(sessionId, content, _verbosity) {
1138
1070
  const ctx = this._sessionContexts.get(sessionId);
1071
+ const planEntries = content.metadata?.entries ?? [];
1139
1072
  if (!ctx)
1140
1073
  return;
1141
1074
  const { context } = ctx;
1142
- const entries = content.metadata?.entries ?? [];
1143
- const mode = this.resolveMode(sessionId);
1144
- const cardData = renderPlanCard(entries, mode);
1145
- const card = { type: "AdaptiveCard", version: "1.2", ...cardData };
1146
- try {
1147
- await this.sendActivityWithRetry(context, { attachments: [CardFactory.adaptiveCard(card)] });
1075
+ const conversationId = context.activity.conversation?.id;
1076
+ const entries = planEntries;
1077
+ const text = formatPlan(entries, "high");
1078
+ const planRef = this.composer.getPlanRef(sessionId);
1079
+ if (planRef) {
1080
+ // Update existing plan message via rate limiter
1081
+ await this.rateLimiter.enqueue(conversationId, async () => {
1082
+ const token = await this.acquireBotToken();
1083
+ if (!token)
1084
+ return;
1085
+ const url = `${planRef.serviceUrl}/v3/conversations/${encodeURIComponent(planRef.conversationId)}/activities/${encodeURIComponent(planRef.activityId)}`;
1086
+ await fetch(url, {
1087
+ method: "PUT",
1088
+ headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
1089
+ body: JSON.stringify({ type: "message", text: text.replace(/(?<!\n)\n(?!\n)/g, "\n\n"), textFormat: "markdown" }),
1090
+ });
1091
+ }, `plan:${sessionId}`);
1148
1092
  }
1149
- catch (err) {
1150
- log.error({ err, sessionId }, "[TeamsAdapter] handlePlan: sendActivity failed");
1093
+ else {
1094
+ // Prevent TOCTOU: if another plan event is already sending the first message, skip
1095
+ if (this._planSending.has(sessionId))
1096
+ return;
1097
+ this._planSending.add(sessionId);
1098
+ try {
1099
+ const result = await this.rateLimiter.enqueue(conversationId, async () => {
1100
+ return sendText(context, text);
1101
+ }, `plan:${sessionId}`);
1102
+ if (result?.id) {
1103
+ this.composer.setPlanRef(sessionId, {
1104
+ activityId: result.id,
1105
+ conversationId,
1106
+ serviceUrl: context.activity.serviceUrl,
1107
+ });
1108
+ }
1109
+ }
1110
+ catch (err) {
1111
+ log.warn({ err, sessionId }, "[TeamsAdapter] handlePlan: send failed");
1112
+ }
1113
+ finally {
1114
+ this._planSending.delete(sessionId);
1115
+ }
1151
1116
  }
1152
1117
  }
1153
1118
  async handleUsage(sessionId, content, _verbosity) {
1154
1119
  const ctx = this._sessionContexts.get(sessionId);
1155
1120
  if (!ctx)
1156
1121
  return;
1157
- const { context, isAssistant } = ctx;
1158
- // Append usage as a subtle footer on the draft before finalizing
1159
1122
  const meta = content.metadata;
1160
1123
  if (meta?.tokensUsed != null) {
1161
- const draft = this.draftManager.getDraft(sessionId);
1162
- if (draft) {
1163
- const parts = [];
1164
- parts.push(`${formatTokens(meta.tokensUsed)} tokens`);
1165
- if (meta.duration != null)
1166
- parts.push(`${(meta.duration / 1000).toFixed(1)}s`);
1167
- if (meta.cost != null)
1168
- parts.push(`$${meta.cost.toFixed(4)}`);
1169
- draft.append(`\n\n---\n*${parts.join(" · ")}*`);
1170
- }
1171
- }
1172
- // Grab the draft ref before finalize (finalize deletes the draft from the map)
1173
- const draft = this.draftManager.getDraft(sessionId);
1174
- await this.draftManager.finalize(sessionId, context, isAssistant);
1175
- const finalRef = draft?.getFinalRef();
1176
- // In DMs, edit the last message to append "Task completed" to the usage footer.
1177
- // In channels, send a separate notification to the notification channel.
1178
- const convType = context.activity?.conversation?.conversationType;
1179
- if (convType === "personal" && finalRef) {
1180
- // Edit the last message's usage footer in-place
1181
- void (async () => {
1182
- try {
1183
- const botToken = await this.acquireBotToken();
1184
- if (!botToken)
1185
- return;
1186
- const updatedText = finalRef.text.replace(/(\n---\n\*.+)\*\s*$/, `$1 · Task completed*`);
1187
- if (updatedText === finalRef.text)
1188
- return;
1189
- const url = `${finalRef.serviceUrl}/v3/conversations/${encodeURIComponent(finalRef.conversationId)}/activities/${encodeURIComponent(finalRef.activityId)}`;
1190
- await fetch(url, {
1191
- method: "PUT",
1192
- headers: { "Content-Type": "application/json", "Authorization": `Bearer ${botToken}` },
1193
- body: JSON.stringify({ type: "message", text: updatedText.replace(/(?<!\n)\n(?!\n)/g, "\n\n"), textFormat: "markdown" }),
1194
- });
1195
- // Best effort — result not checked
1196
- }
1197
- catch {
1198
- // Best effort — user already sees the usage footer
1199
- }
1200
- })();
1201
- }
1202
- else if (this.notificationChannelId && sessionId !== this.assistantSession?.id) {
1203
- const sess = this.core.sessionManager.getSession(sessionId);
1204
- const name = sess?.name || "Session";
1205
- void this.sendNotification({
1206
- sessionId,
1207
- sessionName: name,
1208
- type: "completed",
1209
- summary: "Task completed",
1210
- });
1124
+ const msg = this.composer.getOrCreate(sessionId, ctx.context);
1125
+ const parts = [];
1126
+ parts.push(`${formatTokens(meta.tokensUsed)} tokens`);
1127
+ if (meta.duration != null)
1128
+ parts.push(`${(meta.duration / 1000).toFixed(1)}s`);
1129
+ if (meta.cost != null)
1130
+ parts.push(`$${meta.cost.toFixed(4)}`);
1131
+ parts.push("Task completed");
1132
+ const footerText = parts.join(" · ");
1133
+ msg.setFooter(footerText);
1134
+ // Usage signals end of a turn — clear ephemeral header
1135
+ msg.clearHeader();
1211
1136
  }
1212
1137
  }
1213
1138
  /** Suggested quick-reply actions (Teams restricts these to 1:1 personal chat only) */
@@ -1230,20 +1155,14 @@ export class TeamsAdapter extends MessagingAdapter {
1230
1155
  return {};
1231
1156
  }
1232
1157
  /**
1233
- * Clean up all per-session state (contexts, drafts, dispatch queues, output modes).
1234
- * Removes both sessionId and threadId entries from _sessionContexts to prevent leaks.
1158
+ * Clean up all per-session state (contexts, composer, output modes).
1235
1159
  */
1236
1160
  cleanupSessionState(sessionId) {
1237
- // Find and remove the threadId entry that may also reference this session's context.
1238
- // First try the stored threadId on the context entry itself (reliable even if the
1239
- // session has already been removed from the session manager).
1240
1161
  const entry = this._sessionContexts.get(sessionId);
1241
1162
  const storedThreadId = entry?.threadId;
1242
1163
  if (storedThreadId && storedThreadId !== sessionId) {
1243
1164
  this._sessionContexts.delete(storedThreadId);
1244
1165
  }
1245
- // Fallback: also check session manager and session record in case the context entry
1246
- // was already removed or the threadId wasn't stored.
1247
1166
  const session = this.core.sessionManager.getSession(sessionId);
1248
1167
  const threadId = session?.threadId;
1249
1168
  if (threadId && threadId !== sessionId && threadId !== storedThreadId) {
@@ -1256,28 +1175,30 @@ export class TeamsAdapter extends MessagingAdapter {
1256
1175
  }
1257
1176
  this._sessionContexts.delete(sessionId);
1258
1177
  this._sessionOutputModes.delete(sessionId);
1259
- this._dispatchQueues.delete(sessionId);
1260
- this.draftManager.cleanup(sessionId);
1178
+ this._planSending.delete(sessionId);
1179
+ this.composer.cleanup(sessionId);
1261
1180
  }
1262
1181
  async handleSessionEnd(sessionId, _content) {
1263
1182
  const ctx = this._sessionContexts.get(sessionId);
1264
1183
  if (!ctx)
1265
1184
  return;
1266
- const { context, isAssistant } = ctx;
1267
- await this.draftManager.finalize(sessionId, context, isAssistant);
1185
+ const msg = this.composer.get(sessionId);
1186
+ if (msg) {
1187
+ msg.appendFooter("Task completed");
1188
+ }
1189
+ const ref = await this.composer.finalize(sessionId);
1268
1190
  this.cleanupSessionState(sessionId);
1269
1191
  }
1270
1192
  async handleError(sessionId, content) {
1271
1193
  const ctx = this._sessionContexts.get(sessionId);
1272
1194
  if (!ctx)
1273
1195
  return;
1274
- const { context, isAssistant } = ctx;
1275
- await this.draftManager.finalize(sessionId, context, isAssistant);
1196
+ await this.composer.finalize(sessionId);
1276
1197
  this.cleanupSessionState(sessionId);
1277
1198
  try {
1278
- await this.sendActivityWithRetry(context, {
1199
+ await this.sendActivityWithRetry(ctx.context, {
1279
1200
  text: `❌ **Error:** ${content.text}`,
1280
- ...this.getQuickActions(context),
1201
+ ...this.getQuickActions(ctx.context),
1281
1202
  });
1282
1203
  }
1283
1204
  catch { /* best effort */ }
@@ -1289,33 +1210,29 @@ export class TeamsAdapter extends MessagingAdapter {
1289
1210
  const ctx = this._sessionContexts.get(sessionId);
1290
1211
  if (!ctx)
1291
1212
  return;
1292
- const { context, isAssistant } = ctx;
1293
- // Strip TTS markers from the draft BEFORE finalizing — finalize() deletes
1294
- // the draft from the map, so getDraft() would return undefined after it.
1213
+ // Strip TTS markers from body before sending attachment
1295
1214
  if (attachment.type === "audio") {
1296
- const draft = this.draftManager.getDraft(sessionId);
1297
- if (draft) {
1298
- await draft.stripPattern(/\[TTS\][\s\S]*?\[\/TTS\]/g).catch((err) => {
1299
- log.warn({ err, sessionId }, "[TeamsAdapter] handleAttachment: stripPattern failed");
1300
- });
1215
+ const msg = this.composer.get(sessionId);
1216
+ if (msg) {
1217
+ await msg.stripPattern(/\[TTS\][\s\S]*?\[\/TTS\]/g).catch(() => { });
1301
1218
  }
1302
1219
  }
1303
- await this.draftManager.finalize(sessionId, context, isAssistant);
1304
1220
  if (isAttachmentTooLarge(attachment.size)) {
1305
1221
  log.warn({ sessionId, fileName: attachment.fileName, size: attachment.size }, "[TeamsAdapter] File too large");
1306
- try {
1307
- await this.sendActivityWithRetry(context, {
1308
- text: `⚠️ File too large to send (${Math.round(attachment.size / 1024 / 1024)}MB): ${attachment.fileName}`,
1309
- });
1310
- }
1311
- catch { /* best effort */ }
1222
+ const msg = this.composer.getOrCreate(sessionId, ctx.context);
1223
+ msg.appendBody(`\n\n⚠️ File too large to send (${Math.round(attachment.size / 1024 / 1024)}MB): ${attachment.fileName}`);
1312
1224
  return;
1313
1225
  }
1314
1226
  try {
1315
- // Upload to OneDrive via Graph API if available, get a sharing URL
1316
1227
  const shareUrl = await uploadFileViaGraph(this.graphClient, sessionId, attachment.filePath, attachment.fileName, attachment.mimeType);
1317
- const card = buildFileAttachmentCard(attachment.fileName, attachment.size, attachment.mimeType, shareUrl ?? undefined);
1318
- await this.sendActivityWithRetry(context, { attachments: [CardFactory.adaptiveCard(card)] });
1228
+ // Append file info inline in the body
1229
+ const msg = this.composer.getOrCreate(sessionId, ctx.context);
1230
+ if (shareUrl) {
1231
+ msg.appendBody(`\n\n📎 [${attachment.fileName}](${shareUrl})`);
1232
+ }
1233
+ else {
1234
+ msg.appendBody(`\n\n📎 ${attachment.fileName} (${Math.round(attachment.size / 1024)}KB)`);
1235
+ }
1319
1236
  }
1320
1237
  catch (err) {
1321
1238
  log.error({ err, sessionId, fileName: attachment.fileName }, "[TeamsAdapter] Failed to send attachment");
@@ -1327,20 +1244,22 @@ export class TeamsAdapter extends MessagingAdapter {
1327
1244
  const ctx = this._sessionContexts.get(sessionId);
1328
1245
  if (!ctx)
1329
1246
  return;
1330
- const { context } = ctx;
1331
1247
  try {
1332
- await this.sendActivityWithRetry(context, { text: content.text });
1248
+ await this.sendActivityWithRetry(ctx.context, { text: `⚙️ ${content.text}` });
1333
1249
  }
1334
1250
  catch { /* best effort */ }
1335
1251
  }
1252
+ /** Sanitize metadata strings for safe markdown interpolation. */
1253
+ static sanitizeMd(text) {
1254
+ return text.replace(/[*_~`[\]()\\]/g, "").slice(0, 200);
1255
+ }
1336
1256
  async handleModeChange(sessionId, content) {
1337
1257
  const ctx = this._sessionContexts.get(sessionId);
1338
1258
  if (!ctx)
1339
1259
  return;
1340
- const renderer = this.renderer;
1341
- const rendered = renderer.renderModeChange(content);
1260
+ const modeId = TeamsAdapter.sanitizeMd(String(content.metadata?.modeId ?? ""));
1342
1261
  try {
1343
- await this.sendActivityWithRetry(ctx.context, { text: rendered.body });
1262
+ await this.sendActivityWithRetry(ctx.context, { text: `⚙️ **Mode:** ${modeId}` });
1344
1263
  }
1345
1264
  catch { /* best effort */ }
1346
1265
  }
@@ -1348,10 +1267,10 @@ export class TeamsAdapter extends MessagingAdapter {
1348
1267
  const ctx = this._sessionContexts.get(sessionId);
1349
1268
  if (!ctx)
1350
1269
  return;
1351
- const renderer = this.renderer;
1352
- const rendered = renderer.renderConfigUpdate(content);
1270
+ const key = content.metadata?.key;
1271
+ const detail = key ? ` \`${TeamsAdapter.sanitizeMd(String(key))}\`` : "";
1353
1272
  try {
1354
- await this.sendActivityWithRetry(ctx.context, { text: rendered.body });
1273
+ await this.sendActivityWithRetry(ctx.context, { text: `⚙️ **Config updated**${detail}` });
1355
1274
  }
1356
1275
  catch { /* best effort */ }
1357
1276
  }
@@ -1359,10 +1278,9 @@ export class TeamsAdapter extends MessagingAdapter {
1359
1278
  const ctx = this._sessionContexts.get(sessionId);
1360
1279
  if (!ctx)
1361
1280
  return;
1362
- const renderer = this.renderer;
1363
- const rendered = renderer.renderModelUpdate(content);
1281
+ const modelId = TeamsAdapter.sanitizeMd(String(content.metadata?.modelId ?? ""));
1364
1282
  try {
1365
- await this.sendActivityWithRetry(ctx.context, { text: rendered.body });
1283
+ await this.sendActivityWithRetry(ctx.context, { text: `⚙️ **Model:** ${modelId}` });
1366
1284
  }
1367
1285
  catch { /* best effort */ }
1368
1286
  }
@@ -1394,9 +1312,7 @@ export class TeamsAdapter extends MessagingAdapter {
1394
1312
  return;
1395
1313
  const rawUrl = content.metadata?.url;
1396
1314
  const rawName = content.metadata?.name;
1397
- // Only allow http/https URLs to prevent javascript: or data: scheme injection
1398
1315
  const url = rawUrl && /^https?:\/\//i.test(rawUrl) ? rawUrl : undefined;
1399
- // Sanitize name to prevent markdown injection — strip characters that break link syntax
1400
1316
  const name = rawName?.replace(/[\[\]\(\)]/g, "") || undefined;
1401
1317
  const text = url ? `📎 [${name || url}](${url})` : content.text;
1402
1318
  try {
@@ -1569,7 +1485,12 @@ export class TeamsAdapter extends MessagingAdapter {
1569
1485
  });
1570
1486
  }
1571
1487
  catch { /* best effort */ }
1572
- log.debug({ sessionId, newName }, "[TeamsAdapter] renameSessionThread name stored locally (Teams API does not support conversation rename)");
1488
+ // Update the main message title if the session has an active composer
1489
+ const msg = this.composer.get(sessionId);
1490
+ if (msg && newName) {
1491
+ msg.setTitle(newName);
1492
+ }
1493
+ log.debug({ sessionId, newName }, "[TeamsAdapter] renameSessionThread — title updated");
1573
1494
  }
1574
1495
  async deleteSessionThread(sessionId) {
1575
1496
  const record = this.core.sessionManager.getSessionRecord(sessionId);