diff --git a/extensions/msteams/src/mentions.test.ts b/extensions/msteams/src/mentions.test.ts
new file mode 100644
index 000000000..bfd66873e
--- /dev/null
+++ b/extensions/msteams/src/mentions.test.ts
@@ -0,0 +1,211 @@
+import { describe, expect, it } from "vitest";
+import { buildMentionEntities, formatMentionText, parseMentions } from "./mentions.js";
+
+describe("parseMentions", () => {
+ it("parses single mention", () => {
+ const result = parseMentions("Hello @[John Doe](28:a1b2c3-d4e5f6)!");
+
+ expect(result.text).toBe("Hello John Doe!");
+ expect(result.entities).toHaveLength(1);
+ expect(result.entities[0]).toEqual({
+ type: "mention",
+ text: "John Doe",
+ mentioned: {
+ id: "28:a1b2c3-d4e5f6",
+ name: "John Doe",
+ },
+ });
+ });
+
+ it("parses multiple mentions", () => {
+ const result = parseMentions("Hey @[Alice](28:aaa) and @[Bob](28:bbb), can you review this?");
+
+ expect(result.text).toBe("Hey Alice and Bob, can you review this?");
+ expect(result.entities).toHaveLength(2);
+ expect(result.entities[0]).toEqual({
+ type: "mention",
+ text: "Alice",
+ mentioned: {
+ id: "28:aaa",
+ name: "Alice",
+ },
+ });
+ expect(result.entities[1]).toEqual({
+ type: "mention",
+ text: "Bob",
+ mentioned: {
+ id: "28:bbb",
+ name: "Bob",
+ },
+ });
+ });
+
+ it("handles text without mentions", () => {
+ const result = parseMentions("Hello world!");
+
+ expect(result.text).toBe("Hello world!");
+ expect(result.entities).toHaveLength(0);
+ });
+
+ it("handles empty text", () => {
+ const result = parseMentions("");
+
+ expect(result.text).toBe("");
+ expect(result.entities).toHaveLength(0);
+ });
+
+ it("handles mention with spaces in name", () => {
+ const result = parseMentions("@[John Peter Smith](28:a1b2c3)");
+
+ expect(result.text).toBe("John Peter Smith");
+ expect(result.entities[0]?.mentioned.name).toBe("John Peter Smith");
+ });
+
+ it("trims whitespace from id and name", () => {
+ const result = parseMentions("@[ John Doe ]( 28:a1b2c3 )");
+
+ expect(result.entities[0]).toEqual({
+ type: "mention",
+ text: "John Doe",
+ mentioned: {
+ id: "28:a1b2c3",
+ name: "John Doe",
+ },
+ });
+ });
+
+ it("handles Japanese characters in mention at start of message", () => {
+ const input = "@[タナカ タロウ](a1b2c3d4-e5f6-7890-abcd-ef1234567890) スキル化完了しました!";
+ const result = parseMentions(input);
+
+ expect(result.text).toBe("タナカ タロウ スキル化完了しました!");
+ expect(result.entities).toHaveLength(1);
+ expect(result.entities[0]).toEqual({
+ type: "mention",
+ text: "タナカ タロウ",
+ mentioned: {
+ id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
+ name: "タナカ タロウ",
+ },
+ });
+
+ // Verify entity text exactly matches what's in the formatted text
+ const entityText = result.entities[0]?.text;
+ expect(result.text).toContain(entityText);
+ expect(result.text.indexOf(entityText)).toBe(0);
+ });
+
+ it("skips mention-like patterns with non-Teams IDs (e.g. in code blocks)", () => {
+ // This reproduces the actual failing payload: the message contains a real mention
+ // plus `@[表示名](ユーザーID)` as documentation text inside backticks.
+ const input =
+ "@[タナカ タロウ](a1b2c3d4-e5f6-7890-abcd-ef1234567890) スキル化完了しました!📋\n\n" +
+ "**作成したスキル:** `teams-mention`\n" +
+ "- 機能: Teamsでのメンション形式 `@[表示名](ユーザーID)`\n\n" +
+ "**追加対応:**\n" +
+ "- ユーザーのID `a1b2c3d4-e5f6-7890-abcd-ef1234567890` を登録済み";
+ const result = parseMentions(input);
+
+ // Only the real mention should be parsed; the documentation example should be left as-is
+ expect(result.entities).toHaveLength(1);
+ expect(result.entities[0]?.mentioned.id).toBe("a1b2c3d4-e5f6-7890-abcd-ef1234567890");
+ expect(result.entities[0]?.mentioned.name).toBe("タナカ タロウ");
+
+ // The documentation pattern must remain untouched in the text
+ expect(result.text).toContain("`@[表示名](ユーザーID)`");
+ });
+
+ it("accepts Bot Framework IDs (28:xxx)", () => {
+ const result = parseMentions("@[Bot](28:abc-123)");
+ expect(result.entities).toHaveLength(1);
+ expect(result.entities[0]?.mentioned.id).toBe("28:abc-123");
+ });
+
+ it("accepts AAD object IDs (UUIDs)", () => {
+ const result = parseMentions("@[User](a1b2c3d4-e5f6-7890-abcd-ef1234567890)");
+ expect(result.entities).toHaveLength(1);
+ expect(result.entities[0]?.mentioned.id).toBe("a1b2c3d4-e5f6-7890-abcd-ef1234567890");
+ });
+
+ it("rejects non-ID strings as mention targets", () => {
+ const result = parseMentions("See @[docs](https://example.com) for details");
+ expect(result.entities).toHaveLength(0);
+ // Original text preserved
+ expect(result.text).toBe("See @[docs](https://example.com) for details");
+ });
+});
+
+describe("buildMentionEntities", () => {
+ it("builds entities from mention info", () => {
+ const mentions = [
+ { id: "28:aaa", name: "Alice" },
+ { id: "28:bbb", name: "Bob" },
+ ];
+
+ const entities = buildMentionEntities(mentions);
+
+ expect(entities).toHaveLength(2);
+ expect(entities[0]).toEqual({
+ type: "mention",
+ text: "Alice",
+ mentioned: {
+ id: "28:aaa",
+ name: "Alice",
+ },
+ });
+ expect(entities[1]).toEqual({
+ type: "mention",
+ text: "Bob",
+ mentioned: {
+ id: "28:bbb",
+ name: "Bob",
+ },
+ });
+ });
+
+ it("handles empty list", () => {
+ const entities = buildMentionEntities([]);
+ expect(entities).toHaveLength(0);
+ });
+});
+
+describe("formatMentionText", () => {
+ it("formats text with single mention", () => {
+ const text = "Hello @John!";
+ const mentions = [{ id: "28:xxx", name: "John" }];
+
+ const result = formatMentionText(text, mentions);
+
+ expect(result).toBe("Hello John!");
+ });
+
+ it("formats text with multiple mentions", () => {
+ const text = "Hey @Alice and @Bob";
+ const mentions = [
+ { id: "28:aaa", name: "Alice" },
+ { id: "28:bbb", name: "Bob" },
+ ];
+
+ const result = formatMentionText(text, mentions);
+
+ expect(result).toBe("Hey Alice and Bob");
+ });
+
+ it("handles case-insensitive matching", () => {
+ const text = "Hey @alice and @ALICE";
+ const mentions = [{ id: "28:aaa", name: "Alice" }];
+
+ const result = formatMentionText(text, mentions);
+
+ expect(result).toBe("Hey Alice and Alice");
+ });
+
+ it("handles text without mentions", () => {
+ const text = "Hello world";
+ const mentions = [{ id: "28:xxx", name: "John" }];
+
+ const result = formatMentionText(text, mentions);
+
+ expect(result).toBe("Hello world");
+ });
+});
diff --git a/extensions/msteams/src/mentions.ts b/extensions/msteams/src/mentions.ts
new file mode 100644
index 000000000..7ff3a3578
--- /dev/null
+++ b/extensions/msteams/src/mentions.ts
@@ -0,0 +1,113 @@
+/**
+ * MS Teams mention handling utilities.
+ *
+ * Mentions in Teams require:
+ * 1. Text containing Name tags
+ * 2. entities array with mention metadata
+ */
+
+export type MentionEntity = {
+ type: "mention";
+ text: string;
+ mentioned: {
+ id: string;
+ name: string;
+ };
+};
+
+export type MentionInfo = {
+ /** User/bot ID (e.g., "28:xxx" or AAD object ID) */
+ id: string;
+ /** Display name */
+ name: string;
+};
+
+/**
+ * Check whether an ID looks like a valid Teams user/bot identifier.
+ * Accepts:
+ * - Bot Framework IDs: "28:xxx..." or "29:xxx..."
+ * - AAD object IDs (UUIDs): "d5318c29-33ac-4e6b-bd42-57b8b793908f"
+ *
+ * This prevents false positives from text like `@[表示名](ユーザーID)`
+ * that appears in code snippets or documentation within messages.
+ */
+const TEAMS_ID_PATTERN =
+ /^(?:\d+:[a-f0-9-]+|[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})$/i;
+
+function isValidTeamsId(id: string): boolean {
+ return TEAMS_ID_PATTERN.test(id);
+}
+
+/**
+ * Parse mentions from text in the format @[Name](id).
+ * Example: "Hello @[John Doe](28:xxx-yyy-zzz)!"
+ *
+ * Only matches where the id looks like a real Teams user/bot ID are treated
+ * as mentions. This avoids false positives from documentation or code samples
+ * embedded in the message (e.g. `@[表示名](ユーザーID)` in backticks).
+ *
+ * Returns both the formatted text with tags and the entities array.
+ */
+export function parseMentions(text: string): {
+ text: string;
+ entities: MentionEntity[];
+} {
+ const mentionPattern = /@\[([^\]]+)\]\(([^)]+)\)/g;
+ const entities: MentionEntity[] = [];
+
+ // Replace @[Name](id) with Name only for valid Teams IDs
+ const formattedText = text.replace(mentionPattern, (match, name, id) => {
+ const trimmedId = id.trim();
+
+ // Skip matches where the id doesn't look like a real Teams identifier
+ if (!isValidTeamsId(trimmedId)) {
+ return match;
+ }
+
+ const trimmedName = name.trim();
+ const mentionTag = `${trimmedName}`;
+ entities.push({
+ type: "mention",
+ text: mentionTag,
+ mentioned: {
+ id: trimmedId,
+ name: trimmedName,
+ },
+ });
+ return mentionTag;
+ });
+
+ return {
+ text: formattedText,
+ entities,
+ };
+}
+
+/**
+ * Build mention entities array from a list of mentions.
+ * Use this when you already have the mention info and formatted text.
+ */
+export function buildMentionEntities(mentions: MentionInfo[]): MentionEntity[] {
+ return mentions.map((mention) => ({
+ type: "mention",
+ text: `${mention.name}`,
+ mentioned: {
+ id: mention.id,
+ name: mention.name,
+ },
+ }));
+}
+
+/**
+ * Format text with mentions using tags.
+ * This is a convenience function when you want to manually format mentions.
+ */
+export function formatMentionText(text: string, mentions: MentionInfo[]): string {
+ let formatted = text;
+ for (const mention of mentions) {
+ // Replace @Name or @name with Name
+ const namePattern = new RegExp(`@${mention.name}`, "gi");
+ formatted = formatted.replace(namePattern, `${mention.name}`);
+ }
+ return formatted;
+}
diff --git a/extensions/msteams/src/messenger.ts b/extensions/msteams/src/messenger.ts
index 11b04db8e..e29c620b2 100644
--- a/extensions/msteams/src/messenger.ts
+++ b/extensions/msteams/src/messenger.ts
@@ -19,6 +19,7 @@ import {
uploadAndShareSharePoint,
} from "./graph-upload.js";
import { extractFilename, extractMessageId, getMimeType, isLocalPath } from "./media-helpers.js";
+import { parseMentions } from "./mentions.js";
import { getMSTeamsRuntime } from "./runtime.js";
/**
@@ -269,7 +270,14 @@ async function buildActivity(
const activity: Record = { type: "message" };
if (msg.text) {
- activity.text = msg.text;
+ // Parse mentions from text (format: @[Name](id))
+ const { text: formattedText, entities } = parseMentions(msg.text);
+ activity.text = formattedText;
+
+ // Add mention entities if any mentions were found
+ if (entities.length > 0) {
+ activity.entities = entities;
+ }
}
if (msg.mediaUrl) {