feat(frontend): support extension providers in composer mentions (#6189)

* feat(frontend): support extension providers in composer mentions

* fix(mentions): retain settled plugin suggestions during search

* fix(extensions): validate identifiers and skip empty mention searches

* test: stabilize history pagination after latest page refresh

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Wenchao An 2026-10-03 20:30:23 +08:00 committed by GitHub
parent aeeee2994f
commit 59b051b0fd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 1144 additions and 8 deletions

View File

@ -383,3 +383,59 @@ To exercise the actual Turbopack development build, start the frontend with
`DEER_FLOW_DEV_BUNDLER=turbo pnpm dev`, then run
`PLAYWRIGHT_SKIP_WEB_SERVER=1 pnpm exec playwright test tests/e2e/bookmark-plugin.spec.ts`.
Set `PLAYWRIGHT_BASE_URL` if the development server uses a port other than 3000.
### Composer mention providers
An optional `mentionProviders` array on a browser module contributes candidates to
both the native `@` menu and the mobile mention picker. No custom editor is needed:
```javascript
export default {
apiVersion: 1,
module: "example-team",
mentionProviders: [{
id: "members",
label: "Team members",
async search(query, context) {
return context.callBackend("find_members", { query });
},
}],
};
```
Declare `find_members` in the package's backend actions. Its response is an array
of `{ id, label, description? }`; the host supplies the namespace and provider ID.
The context includes `locale`, `settings`, `threadId`, an `AbortSignal`, and the
same viewer-bound `callBackend` service used by page surfaces. Observe the signal
when doing asynchronous work. A provider must return a promise; the host debounces
queries by 150 ms, truncates queries to 256 characters before calling `search`,
stops waiting after three seconds, and discards late responses after query,
thread, viewer, locale, or installed snapshot changes. Settled candidates remain
visible while a new query is in flight within the same viewer/thread/locale and
installed snapshot; changing any of those clears them immediately. A failed
provider does not remove other providers or built-in candidates. Retrying is
available in the picker.
Cancellation ends host waiting; trusted JavaScript cannot be forcibly terminated.
Each module may register eight providers, with unique lowercase slug IDs and
labels of at most 120 characters. The picker searches the first 16 providers in
installation order and considers at most 50 results per provider. Item IDs are
nonempty strings of at most 512 characters, labels at most 120, descriptions at
most 240. Duplicate item IDs within a provider are ignored. IDs are namespaced,
so two plugins can use the same local identifier.
Selections remain in the thread's existing draft as inline reference tokens.
Submitting renders each token as `@label` in the human message and attaches:
```json
{"extension_mentions":[{"namespace":"example.team","provider":"members","id":"alice","label":"Alice"}]}
```
This object is in the human message's `additional_kwargs`. Up to 16 distinct
plugin references may be submitted. Removing a token removes its metadata;
duplicate tokens share one metadata entry. Restored references retain their
original identities even if a package later becomes unavailable. **All metadata
is user input, not authorization**: plugins must revalidate the referenced object,
current installation, and viewer permissions before acting. The host does not
interpret a reference as delegation or start another Agent. Those behaviors
belong to the plugin's backend handlers or middleware.

View File

@ -217,3 +217,16 @@ reset on conversation changes. Gateway supplies defaults for clients without a
selector; frontend visibility must not become a runtime enforcement boundary.
MCP hooks and the editor default to personal scope. Mutation-option builders require an explicit scope. MCP control accessible names include the localized ownership section, so same-named shared and personal connections remain distinct without region context. Query keys include scope and authenticated user ID; remount each editor when that ID changes. Platform provided uses the deployment editor only for administrators outside static mode, restoring add, edit, toggle and delete through the existing admin-only MCP API. Ordinary users see shared entries read-only. My plugins contains personal configuration templates, Lark account authorization and the personal MCP editor; a deployment installation must not mark a personal template configured.
Plugin mention providers extend the existing composer picker via
`core/extensions/use-mentions.ts`. Keep provider queries bounded and fenced by
viewer, thread, query, and installed snapshot.
Provider and page-surface IDs must be strings before slug validation; JavaScript
coercion is not validation. A settled snapshot with no enabled, viewer-visible
mention providers returns an empty result immediately without a search timer.
Settled results may remain visible during query/retry refreshes only within the
same viewer, thread, locale and installed snapshot; stale responses stay fenced.
Inline plugin references serialize into human-message
`additional_kwargs.extension_mentions`; these IDs and labels
are untrusted input, never a routing or permission grant. Built-in mentions must
remain usable when a provider fails or times out.

View File

@ -78,6 +78,10 @@ import {
buildConversationReferenceMetadata,
type ConversationReference,
} from "@/core/conversation-references";
import {
extensionMentionId,
MAX_EXTENSION_MENTIONS,
} from "@/core/extensions/mentions";
import { useConversationReferencesCapability } from "@/core/features/hooks";
import { useI18n } from "@/core/i18n/hooks";
import { polishInputDraft } from "@/core/input-polish/api";
@ -169,6 +173,7 @@ import {
type SlashCommandSuggestion,
} from "./input-box-helpers";
import {
extensionMentionMetadata,
inlineReferences,
reconcileConversationReferences,
MAX_EXPLICIT_SKILLS,
@ -1463,12 +1468,21 @@ export function InputBox({
toast.warning(t.inputBox.mentionMultipleSkills);
return Promise.reject(new Error("Too many skill references."));
}
const extensionMetadata = extensionMentionMetadata(textInput.value);
if (
(extensionMetadata.extension_mentions?.length ?? 0) >
MAX_EXTENSION_MENTIONS
) {
toast.warning(t.inputBox.mentionExtensionsLimit);
return Promise.reject(new Error("Too many extension mentions"));
}
pendingDraftSubmissionRef.current = {
key: draftKey,
text: textInput.value,
skillName: null,
};
const additionalKwargs = {
...extensionMetadata,
...(skillReferences.length
? { skill_references: skillReferences }
: {}),
@ -1551,6 +1565,7 @@ export function InputBox({
sidecar,
t.inputBox.suggestionPlaceholderRequired,
t.inputBox.mentionMultipleSkills,
t.inputBox.mentionExtensionsLimit,
conversationCapability,
threadId,
uploadLimits,
@ -1845,7 +1860,9 @@ export function InputBox({
? selection.skill.name
: selection.kind === "conversation"
? selection.reference.threadId
: null;
: selection.kind === "extension"
? extensionMentionId(selection.reference)
: null;
const selected =
selectionId &&
inlineReferences(originalText).some(
@ -1883,6 +1900,12 @@ export function InputBox({
selection.reference.threadId,
selection.reference.title,
);
if (selection.kind === "extension")
token = referenceToken(
"extension",
extensionMentionId(selection.reference),
selection.reference.label,
);
if (selection.kind === "file")
token = referenceToken(
"file",
@ -2878,6 +2901,9 @@ export function InputBox({
style={{ maxHeight: mentionPlacement.maxHeight }}
>
<MentionPicker
selectedExtensions={inlineReferences(textInput.value)
.filter((ref) => ref.kind === "extension")
.map((ref) => ref.id)}
ref={mentionPickerRef}
listId={mentionListId}
query={mentionQuery?.query ?? ""}

View File

@ -1,16 +1,21 @@
import type { ConversationReference } from "@/core/conversation-references";
import {
extensionMentionId,
parseExtensionMention,
} from "@/core/extensions/mentions";
export const MAX_EXPLICIT_SKILLS = 16;
export type InlineReference = {
kind: "skill" | "file" | "conversation";
kind: "skill" | "file" | "conversation" | "extension";
id: string;
label: string;
start: number;
end: number;
};
const pattern = /@\[([^\]]*)\]\(ref:(skill|file|conversation):([^)]*)\)/g;
const pattern =
/@\[([^\]]*)\]\(ref:(skill|file|conversation|extension):([^)]*)\)/g;
export function referenceToken(
kind: InlineReference["kind"],
@ -162,18 +167,28 @@ export function renderReferenceEditor(root: HTMLElement, text: string) {
? "project-attachment-chip"
: ref.kind === "conversation"
? "conversation-reference-chip"
: "inline-skill-reference";
: ref.kind === "extension"
? "extension-mention-chip"
: "inline-skill-reference";
token.className =
"inline-flex items-baseline gap-1 align-baseline font-medium select-all " +
(ref.kind === "skill"
? "text-blue-600 dark:text-blue-400"
: ref.kind === "file"
? "text-rose-600 dark:text-rose-400"
: "text-violet-600 dark:text-violet-400");
: ref.kind === "extension"
? "text-teal-600 dark:text-teal-400"
: "text-violet-600 dark:text-violet-400");
const icon = document.createElement("span");
icon.setAttribute("aria-hidden", "true");
icon.textContent =
ref.kind === "skill" ? "✦" : ref.kind === "file" ? "▤" : "◉";
ref.kind === "skill"
? "✦"
: ref.kind === "file"
? "▤"
: ref.kind === "extension"
? "◈"
: "◉";
token.append(icon, document.createTextNode(ref.label));
token.setAttribute("aria-label", `@${ref.label}`);
fragment.append(token);
@ -233,3 +248,19 @@ export function reconcileConversationReferences(
}
return { text, references: [...references.values()] };
}
/** Only references still present in the draft are submitted. */
export function extensionMentionMetadata(text: string) {
const references = new Map<
string,
NonNullable<ReturnType<typeof parseExtensionMention>>
>();
for (const ref of inlineReferences(text)) {
if (ref.kind !== "extension") continue;
const mention = parseExtensionMention(ref.id, ref.label);
if (mention) references.set(extensionMentionId(mention), mention);
}
return references.size
? { extension_mentions: [...references.values()] }
: {};
}

View File

@ -4,6 +4,7 @@ import {
CheckIcon,
FileIcon,
MessagesSquareIcon,
PuzzleIcon,
SparklesIcon,
UploadIcon,
XIcon,
@ -19,6 +20,12 @@ import {
} from "react";
import type { ConversationReference } from "@/core/conversation-references";
import type { ExtensionMention } from "@/core/extensions/contracts";
import {
extensionMentionId,
MAX_EXTENSION_MENTIONS,
} from "@/core/extensions/mentions";
import { useExtensionMentions } from "@/core/extensions/use-mentions";
import { useI18n } from "@/core/i18n/hooks";
import { useInfiniteProjectDocuments } from "@/core/projects/hooks";
import type { ProjectDocument } from "@/core/projects/types";
@ -34,6 +41,7 @@ export type MentionSelection =
| { kind: "skill"; skill: Skill }
| { kind: "file"; document: ProjectDocument }
| { kind: "conversation"; reference: ConversationReference }
| { kind: "extension"; reference: ExtensionMention }
| { kind: "upload" };
export type MentionPickerHandle = {
onKeyDown: (event: KeyboardEvent<HTMLElement>) => void;
@ -58,6 +66,7 @@ export function MentionPicker({
capability,
onActiveOptionChange,
selectedSkills,
selectedExtensions = [],
references,
threadId,
projectId,
@ -83,6 +92,7 @@ export function MentionPicker({
};
onActiveOptionChange?: (id: string | undefined) => void;
selectedSkills?: string[];
selectedExtensions?: string[];
references: ConversationReference[];
threadId: string;
projectId?: string | null;
@ -105,6 +115,10 @@ export function MentionPicker({
const rootRef = useRef<HTMLDivElement>(null);
const compositionEndedAt = useRef(-Infinity);
const filter = (searchInput ? search : query).toLocaleLowerCase();
const extensions = useExtensionMentions(
searchInput ? search : query,
threadId,
);
const options = useMemo(() => {
const result: Option[] = skills
.filter((skill) => skill.enabled)
@ -161,6 +175,19 @@ export function MentionPicker({
.toLocaleLowerCase()
.includes(filter),
);
for (const item of extensions.items) {
const id = extensionMentionId(item);
filtered.push({
id: `extension:${id}`,
label: item.label,
description: item.description,
selection: { kind: "extension", reference: item },
selected: selectedExtensions.includes(id),
disabled:
!selectedExtensions.includes(id) &&
new Set(selectedExtensions).size >= MAX_EXTENSION_MENTIONS,
});
}
filtered.push({
id: "upload",
label: labels.mentionUpload,
@ -168,6 +195,8 @@ export function MentionPicker({
});
return filtered;
}, [
extensions.items,
selectedExtensions,
skills,
selectedSkills,
documents.data,
@ -230,11 +259,13 @@ export function MentionPicker({
?.scrollIntoView?.({ block: "nearest" });
}, [activeOptionId]);
const loading =
extensions.loading ||
skillsLoading ||
capability.isLoading ||
(!!projectId && documents.isPending) ||
(capability.enabled && conversations.isPending);
const failed =
extensions.failed ||
!!capability.error ||
!!skillsError ||
(!!projectId && documents.isError) ||
@ -247,6 +278,7 @@ export function MentionPicker({
title: labels.mentionConversations,
Icon: MessagesSquareIcon,
},
{ kind: "extension", title: labels.mentionExtensions, Icon: PuzzleIcon },
{ kind: "upload", title: "", Icon: UploadIcon },
] as const;
return (
@ -364,6 +396,7 @@ export function MentionPicker({
<button
type="button"
onClick={() => {
if (extensions.failed) extensions.retry();
if (capability.error) void capability.refetch?.();
if (skillsError) void onRetrySkills?.();
if (projectId && documents.isError) void documents.refetch();

View File

@ -72,12 +72,32 @@ export type ConversationActionGroup = {
actions: ConversationAction[];
};
export type ExtensionMention = {
namespace: string;
provider: string;
id: string;
label: string;
};
export type MentionContext = Pick<
SurfaceContext,
"namespace" | "locale" | "settings" | "threadId" | "signal" | "callBackend"
>;
export type MentionProvider = {
id: string;
label: string;
search: (
query: string,
context: MentionContext,
) => Promise<readonly { id: string; label: string; description?: string }[]>;
};
/** Browser package API v1. Modules are installed by trusted deployment operators. */
export interface FrontendExtension {
apiVersion: 1;
module: string;
icon?: string;
surfaces?: PluginSurface[];
mentionProviders?: MentionProvider[];
conversationActions?: (
t: Translations,
locale?: string,

View File

@ -0,0 +1,138 @@
import type { ExtensionMention, MentionContext } from "./contracts";
import { activeFrontendExtensions, type LoadedContribution } from "./registry";
import {
bindFrontendServices,
conversationText,
latestVisibleAnswer,
} from "./services";
export const MAX_EXTENSION_MENTIONS = 16;
const identifier = /^[a-z][a-z0-9-]{0,63}$/;
const bounded = (value: unknown, limit: number): value is string =>
typeof value === "string" && !!value.trim() && value.length <= limit;
export function extensionMentionId(ref: ExtensionMention) {
return JSON.stringify([ref.namespace, ref.provider, ref.id]);
}
/** Draft references and submitted metadata are untrusted labels, never authority. */
export function parseExtensionMention(
id: string,
label: string,
): ExtensionMention | null {
try {
if (id.length > 2048 || !bounded(label, 120)) return null;
const parts: unknown = JSON.parse(id);
if (!Array.isArray(parts) || parts.length !== 3) return null;
const [namespace, provider, itemId] = parts as unknown[];
if (
typeof namespace !== "string" ||
!/^[a-z][a-z0-9_.-]{0,127}$/.test(namespace) ||
typeof provider !== "string" ||
!identifier.test(provider) ||
!bounded(itemId, 512)
)
return null;
return { namespace, provider, id: itemId, label };
} catch {
return null;
}
}
export async function searchExtensionMentions(
entries: LoadedContribution[],
query: string,
context: Pick<MentionContext, "locale" | "threadId" | "signal">,
): Promise<{
items: (ExtensionMention & { description?: string })[];
failed: boolean;
}> {
context.signal.throwIfAborted();
const providers = activeFrontendExtensions(entries)
.flatMap(({ contribution, extension }) =>
(extension.mentionProviders ?? []).map((provider) => ({
contribution,
provider,
})),
)
.slice(0, 16);
const results = await Promise.all(
providers.map(async ({ contribution, provider }) => {
const abort = new AbortController();
const cancel = () => abort.abort();
context.signal.addEventListener("abort", cancel, { once: true });
let timeout: ReturnType<typeof setTimeout> | undefined;
let onAbort: (() => void) | undefined;
try {
const deadline = new Promise<never>((_, reject) => {
onAbort = () => reject(new Error("Mention search cancelled"));
abort.signal.addEventListener("abort", onAbort, { once: true });
timeout = setTimeout(() => abort.abort(), 3000);
});
const services = bindFrontendServices(
{
conversationText,
latestVisibleAnswer,
showMessage: () => undefined,
},
contribution,
abort.signal,
);
const candidates = await Promise.race([
Promise.resolve().then(() => {
abort.signal.throwIfAborted();
return provider.search(query.slice(0, 256), {
...context,
namespace: contribution.namespace,
settings: contribution.settings,
signal: abort.signal,
callBackend: async (action, payload) => {
abort.signal.throwIfAborted();
const result = await services.callBackend(action, payload);
abort.signal.throwIfAborted();
return result;
},
});
}),
deadline,
]);
if (!Array.isArray(candidates))
throw new Error("Invalid mention results");
const seen = new Set<string>();
const items: (ExtensionMention & { description?: string })[] = [];
for (const candidate of candidates.slice(0, 50)) {
if (
!candidate ||
!bounded(candidate.id, 512) ||
!bounded(candidate.label, 120) ||
seen.has(candidate.id)
)
continue;
seen.add(candidate.id);
items.push({
namespace: contribution.namespace,
provider: provider.id,
id: candidate.id,
label: candidate.label,
description: bounded(candidate.description, 240)
? candidate.description
: provider.label,
});
}
return { items, failed: false };
} catch {
return { items: [], failed: true };
} finally {
clearTimeout(timeout);
if (onAbort) abort.signal.removeEventListener("abort", onAbort);
abort.abort();
context.signal.removeEventListener("abort", cancel);
}
}),
);
context.signal.throwIfAborted();
return {
items: results.flatMap((result) => result.items),
failed: results.some((result) => result.failed),
};
}

View File

@ -87,6 +87,28 @@ export async function loadFrontendExtensions(
typeof loadedModule.conversationActions !== "function")
)
throw new Error("Incompatible browser extension");
if (loadedModule.mentionProviders !== undefined) {
const seen = new Set<string>();
if (
!Array.isArray(loadedModule.mentionProviders) ||
loadedModule.mentionProviders.length > 8
)
throw new Error("Invalid mention providers");
for (const provider of loadedModule.mentionProviders) {
if (
!provider ||
typeof provider.id !== "string" ||
!/^[a-z][a-z0-9-]{0,63}$/.test(provider.id) ||
seen.has(provider.id) ||
typeof provider.label !== "string" ||
!provider.label.trim() ||
provider.label.length > 120 ||
typeof provider.search !== "function"
)
throw new Error("Invalid mention provider");
seen.add(provider.id);
}
}
if (loadedModule.surfaces !== undefined) {
const seen = new Set<string>();
if (
@ -97,6 +119,7 @@ export async function loadFrontendExtensions(
for (const surface of loadedModule.surfaces) {
if (
!surface ||
typeof surface.id !== "string" ||
!/^[a-z][a-z0-9-]{0,63}$/.test(surface.id) ||
seen.has(surface.id) ||
surface.slot !== "page" ||

View File

@ -0,0 +1,83 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/core/auth/AuthProvider";
import { useI18n } from "@/core/i18n/hooks";
import { useFrontendExtensions } from "./hooks";
import { searchExtensionMentions } from "./mentions";
const empty = { items: [], failed: false };
export function useExtensionMentions(query: string, threadId: string) {
const { user } = useAuth();
const { locale } = useI18n();
const extensions = useFrontendExtensions();
const [attempt, retry] = useState(0);
const scope = useMemo(
() => ({
userId: user?.id,
locale,
threadId,
entries: extensions.data,
}),
[user?.id, locale, threadId, extensions.data],
);
const hasProviders = (scope.entries ?? []).some(
(entry) =>
entry.viewer_id === scope.userId &&
entry.settings.enabled === true &&
(entry.extension?.mentionProviders?.length ?? 0) > 0,
);
const request = useMemo(
() => ({ scope, query, attempt }),
[scope, query, attempt],
);
const [state, setState] = useState<{
request: typeof request;
result: Awaited<ReturnType<typeof searchExtensionMentions>>;
}>();
useEffect(() => {
if (!hasProviders) return;
const abort = new AbortController();
const timer = setTimeout(() => {
void searchExtensionMentions(
(scope.entries ?? []).filter(
(entry) => entry.viewer_id === scope.userId,
),
request.query,
{
locale: scope.locale,
threadId: scope.threadId,
signal: abort.signal,
},
)
.then((result) => {
if (!abort.signal.aborted) setState({ request, result });
})
.catch(() => {
/* Unmount, account, thread, or query change. */
});
}, 150);
return () => {
clearTimeout(timer);
abort.abort();
};
}, [scope, request, hasProviders]);
// Keep settled suggestions during query/retry refreshes, but never across
// viewer, thread, locale or installed-snapshot changes.
const result = !hasProviders
? empty
: state?.request.scope === scope
? state.result
: undefined;
return {
...(result ?? empty),
failed: extensions.isError || result?.failed === true,
loading: extensions.isPending || result === undefined,
retry: () => {
void extensions.refetch();
retry((value) => value + 1);
},
};
}

View File

@ -379,9 +379,11 @@ export const enUS: Translations = {
// Input Box
inputBox: {
mentionPicker: "Add a reference",
mentionSearch: "Search skills, project files and conversations",
mentionSearch: "Search skills, project files, conversations and plugins",
mentionSkills: "Skills",
mentionFiles: "Project files",
mentionExtensions: "Plugins",
mentionExtensionsLimit: "Select up to 16 plugin references.",
mentionConversations: "Conversations",
mentionUpload: "Upload a file",
mentionEmpty: "No matching references in loaded results",

View File

@ -312,6 +312,8 @@ export interface Translations {
mentionSearch: string;
mentionSkills: string;
mentionFiles: string;
mentionExtensions: string;
mentionExtensionsLimit: string;
mentionConversations: string;
mentionUpload: string;
mentionEmpty: string;

View File

@ -359,9 +359,11 @@ export const zhCN: Translations = {
// Input Box
inputBox: {
mentionPicker: "添加引用",
mentionSearch: "搜索技能、项目文件和对话",
mentionSearch: "搜索技能、项目文件、对话和插件",
mentionSkills: "技能",
mentionFiles: "项目文件",
mentionExtensions: "插件",
mentionExtensionsLimit: "最多选择 16 个插件引用。",
mentionConversations: "对话",
mentionUpload: "上传文件",
mentionEmpty: "已加载的内容中没有匹配的引用",

View File

@ -0,0 +1,138 @@
import { createHash } from "node:crypto";
import { expect, test } from "@playwright/test";
import { mockLangGraphAPI } from "./utils/mock-api";
test("plugin candidates coexist with skills, survive draft reload and submit structured references", async ({
page,
baseURL,
}, testInfo) => {
mockLangGraphAPI(page, {
skills: [
{ name: "research", description: "Research a topic", enabled: true },
],
});
const source = `export default { apiVersion: 1, module: 'team', mentionProviders: [
{ id: 'people', label: 'Team members', search: async (query, ctx) => {
const items = await ctx.callBackend('search', { query, threadId: ctx.threadId });
return items;
} },
{ id: 'broken', label: 'Unavailable', search: async () => { throw Error('offline'); } }
] };`;
const entry = `/api/plugins/modules/team/${createHash("sha256").update(source).digest("hex")}.mjs`;
let enabled = true;
let holdSearch = false;
let releaseSearch: (() => void) | undefined;
const searches: unknown[] = [];
await page.route("**/api/plugins**", async (route) => {
const url = new URL(route.request().url());
const headers = {
"access-control-allow-origin": new URL(baseURL!).origin,
"access-control-allow-credentials": "true",
};
if (url.pathname.endsWith("/api/plugins"))
return route.fulfill({
headers,
json: [
{
namespace: "community.team",
viewer_id: "default",
module: "team",
entry,
title: "Team",
description: "",
settings: { enabled },
backend_actions: ["search"],
},
],
});
if (url.pathname.endsWith(entry))
return route.fulfill({
headers,
contentType: "text/javascript",
body: source,
});
if (url.pathname.endsWith("/actions/search")) {
searches.push(route.request().postDataJSON());
expect(route.request().headers()["x-deerflow-plugin-viewer"]).toBe(
"default",
);
if (holdSearch) {
await new Promise<void>((resolve) => {
releaseSearch = resolve;
});
}
return route.fulfill({
headers,
json: [{ id: "alice", label: "Alice" }],
});
}
return route.fulfill({ status: 404, headers });
});
await page.goto("/workspace/chats/new");
const input = page.getByRole("textbox", { name: /how can i assist you/i });
await input.fill("Ask @");
await expect(
page.getByRole("option", { name: "research Research a topic" }),
).toBeVisible();
await expect(
page.getByRole("option", { name: "Alice Team members" }),
).toBeVisible();
await expect(page.getByRole("listbox")).toHaveAttribute("aria-busy", "false");
holdSearch = true;
const refresh = page.waitForRequest((request) =>
request.url().includes("/actions/search"),
);
await input.press("a");
await refresh;
try {
await expect(
page.getByRole("option", { name: "Alice Team members" }),
).toBeVisible();
await expect(page.getByRole("listbox")).toHaveAttribute(
"aria-busy",
"false",
);
} finally {
holdSearch = false;
releaseSearch?.();
}
await page.screenshot({
path: testInfo.outputPath("extension-mentions.png"),
});
await page.getByRole("option", { name: "Alice Team members" }).click();
await expect(page.getByTestId("extension-mention-chip")).toContainText(
"Alice",
);
await expect(page.getByTestId("extension-mention-chip")).toContainText("◈");
await expect.poll(() => searches.length).toBeGreaterThan(0);
await page.reload();
await expect(page.getByTestId("extension-mention-chip")).toContainText(
"Alice",
);
const request = page.waitForRequest(
(r) => r.method() === "POST" && r.url().includes("/runs/stream"),
);
await input.press("Enter");
const message = (await request).postDataJSON().input.messages.at(-1);
expect(message.content).toEqual([{ type: "text", text: "Ask @Alice" }]);
expect(message.additional_kwargs.extension_mentions).toEqual([
{
namespace: "community.team",
provider: "people",
id: "alice",
label: "Alice",
},
]);
enabled = false;
await page.goto("/workspace/chats/new");
await page.reload();
await input.fill("@");
await expect(
page.getByRole("option", { name: "research Research a topic" }),
).toBeVisible();
await expect(
page.getByRole("option", { name: "Alice Team members" }),
).toHaveCount(0);
});

View File

@ -371,6 +371,17 @@ test.describe("Thread history", () => {
await expect
.poll(() => latestPageRequestCount, { timeout: 15_000 })
.toBeGreaterThan(latestPageRequestsBeforeSubmit);
// The refreshed page extends the virtualized list after submission. Keep
// positioning its measured tail until the follow-up enters the DOM.
await expect
.poll(async () => {
await scroller.evaluate((element) => {
element.scrollTop = element.scrollHeight;
element.dispatchEvent(new Event("scroll"));
});
return page.getByText(followUpPrompt).isVisible();
})
.toBe(true);
await expect(page.getByText(followUpPrompt)).toBeVisible();
let preservedDurationFound = false;

View File

@ -41,6 +41,33 @@ rs.mock("@/core/models/hooks", () => ({
}),
}));
const pluginSearch = rs.fn();
rs.mock("@/core/extensions/hooks", () => ({
useFrontendExtensions: () => ({
data: pluginEntries,
isPending: false,
isError: false,
}),
}));
const pluginEntries = [
{
namespace: "community.team",
viewer_id: "user-1",
module: "team",
entry: null,
title: "Team",
description: "",
settings: { enabled: true },
extension: {
apiVersion: 1,
module: "team",
mentionProviders: [
{ id: "people", label: "People", search: pluginSearch },
],
},
},
];
const skillCatalog = [
{
name: "research",
@ -176,6 +203,8 @@ rs.mock("@/core/threads/hooks", () => ({
}));
beforeEach(() => {
pluginSearch.mockReset();
pluginSearch.mockResolvedValue([]);
skillCatalog.splice(1);
capability.enabled = true;
capability.maxReferences = 3;
@ -228,6 +257,52 @@ function enterMention(
}
describe("unified composer mentions", () => {
it("submits a plugin selection as readable text and structured metadata", async () => {
pluginSearch.mockResolvedValue([{ id: "alice", label: "Alice" }]);
const submit = rs.fn();
const { container } = renderComposer("plugin-mention", submit);
enterMention(container, "Ask @ali");
fireEvent.click(
await screen.findByRole("option", { name: "Alice People" }),
);
expect(screen.getByTestId("extension-mention-chip")).toBeTruthy();
fireEvent.keyDown(container.querySelector('[contenteditable="true"]')!, {
key: "Enter",
});
await waitFor(() => expect(submit).toHaveBeenCalled());
expect(submit.mock.calls[0]![0].text).toBe("Ask @Alice ");
expect(
submit.mock.calls[0]![1].additionalKwargs.extension_mentions,
).toEqual([
{
namespace: "community.team",
provider: "people",
id: "alice",
label: "Alice",
},
]);
});
it("drops metadata when a plugin reference is toggled off", async () => {
pluginSearch.mockResolvedValue([{ id: "alice", label: "Alice" }]);
const submit = rs.fn();
const { container } = renderComposer("remove-plugin-mention", submit);
enterMention(container, "Ask @ali");
fireEvent.click(
await screen.findByRole("option", { name: "Alice People" }),
);
enterMention(container, "@ali");
fireEvent.click(
await screen.findByRole("option", { name: "Alice People" }),
);
expect(screen.queryByTestId("extension-mention-chip")).toBeNull();
fireEvent.keyDown(container.querySelector('[contenteditable="true"]')!, {
key: "Enter",
});
await waitFor(() => expect(submit).toHaveBeenCalled());
expect(
submit.mock.calls[0]![1].additionalKwargs?.extension_mentions,
).toBeUndefined();
});
it("clears cached conversation metadata when the draft owner changes", async () => {
const submit = rs.fn();
const rendered = renderComposer("cache-first", submit);

View File

@ -0,0 +1,32 @@
import { expect, it } from "@rstest/core";
import {
referenceToken,
renderReferenceEditor,
} from "@/components/workspace/mentions/inline-references";
import { extensionMentionId } from "@/core/extensions/mentions";
it("renders plugin references with a distinct glyph and color from conversations", () => {
const root = document.createElement("div");
const plugin = referenceToken(
"extension",
extensionMentionId({
namespace: "community.team",
provider: "people",
id: "alice",
label: "Alice",
}),
"Alice",
);
const conversation = referenceToken("conversation", "thread", "Conversation");
renderReferenceEditor(root, `${plugin} ${conversation}`);
const extensionChip = root.querySelector<HTMLElement>(
'[data-reference-kind="extension"]',
)!;
const conversationChip = root.querySelector<HTMLElement>(
'[data-reference-kind="conversation"]',
)!;
expect(extensionChip.firstElementChild?.textContent).toBe("◈");
expect(conversationChip.firstElementChild?.textContent).toBe("◉");
expect(extensionChip.className).not.toBe(conversationChip.className);
});

View File

@ -0,0 +1,130 @@
import { describe, expect, it, rs } from "@rstest/core";
import {
extensionMentionMetadata,
referenceToken,
} from "@/components/workspace/mentions/inline-references";
import {
extensionMentionId,
parseExtensionMention,
searchExtensionMentions,
} from "@/core/extensions/mentions";
import type { LoadedContribution } from "@/core/extensions/registry";
const entry = (
namespace: string,
search: (...args: never[]) => unknown,
): LoadedContribution => ({
namespace,
module: namespace,
entry: null,
title: namespace,
description: "",
settings: { enabled: true },
extension: {
apiVersion: 1,
module: namespace,
mentionProviders: [
{ id: "people", label: "People", search: search as never },
],
},
});
describe("extension mentions", () => {
it("keeps identities distinct and round trips Unicode and punctuation", () => {
const reference = {
namespace: "community.team",
provider: "people",
id: "陈 ()/1",
label: "陈",
};
expect(
parseExtensionMention(extensionMentionId(reference), reference.label),
).toEqual(reference);
expect(parseExtensionMention('["team","people",{}]', "Bad")).toBeNull();
});
it("isolates rejected providers and rejects invalid or duplicate candidates", async () => {
const result = await searchExtensionMentions(
[
entry("broken", async () => {
throw Error("offline");
}),
entry("team", async () => [
{ id: "a", label: "Alice" },
{ id: "a", label: "Duplicate" },
{ id: {}, label: "Invalid" },
]),
{
...entry("disabled", async () => [{ id: "b", label: "Bob" }]),
settings: { enabled: false },
},
],
"ali",
{
locale: "en-US",
threadId: "thread",
signal: new AbortController().signal,
},
);
expect(result.items).toEqual([
{
namespace: "team",
provider: "people",
id: "a",
label: "Alice",
description: "People",
},
]);
expect(result.failed).toBe(true);
});
it("settles even if a provider ignores cancellation", async () => {
const abort = new AbortController();
const pending = searchExtensionMentions(
[entry("team", () => new Promise(() => undefined))],
"",
{ locale: "en-US", signal: abort.signal },
);
abort.abort();
await expect(pending).rejects.toThrow();
});
});
it("deduplicates selected tokens and drops removed or malformed metadata", () => {
const reference = {
namespace: "community.team",
provider: "people",
id: "陈/1",
label: "陈",
};
const token = referenceToken(
"extension",
extensionMentionId(reference),
reference.label,
);
expect(extensionMentionMetadata(`${token} ${token}`)).toEqual({
extension_mentions: [reference],
});
expect(extensionMentionMetadata("@陈")).toEqual({});
expect(
extensionMentionMetadata(referenceToken("extension", "not-json", "Bad")),
).toEqual({});
});
it("times out an unresponsive provider without losing healthy candidates", async () => {
rs.useFakeTimers();
try {
const pending = searchExtensionMentions(
[
entry("slow", () => new Promise(() => undefined)),
entry("fast", async () => [{ id: "one", label: "Ready" }]),
],
"",
{ locale: "en-US", signal: new AbortController().signal },
);
await rs.advanceTimersByTimeAsync(3000);
const result = await pending;
expect(result.items.map((item) => item.id)).toEqual(["one"]);
expect(result.failed).toBe(true);
} finally {
rs.useRealTimers();
}
});

View File

@ -191,3 +191,100 @@ test("discovery preserves transport negotiation through parsing and isolates new
expect(loaded[1]?.error).toBeTruthy();
expect(assetImporter).toHaveBeenCalledTimes(1);
});
test("validates optional mention providers independently for each installed module", async () => {
const assets = {
...entry,
transport: "assets-v1",
entry: `/api/plugins/${entry.namespace}/assets/${"b".repeat(64)}/index.mjs`,
};
const provider = { id: "people", label: "People", search: async () => [] };
for (const mentionProviders of [
null,
[provider, provider],
[{ ...provider, search: null }],
[{ ...provider, id: "../bad" }],
Array(9).fill(provider),
]) {
const importer = rs
.fn()
.mockResolvedValueOnce({ default: { ...extension, mentionProviders } })
.mockResolvedValueOnce({
default: { ...extension, mentionProviders: [provider] },
});
const loaded = await loadFrontendExtensions(
[assets, assets],
rs.fn(),
importer,
);
expect(loaded[0]?.error).toBeTruthy();
expect(loaded[1]?.extension?.mentionProviders).toEqual([provider]);
}
});
for (const field of ["mentionProviders", "surfaces"] as const) {
for (const [name, id] of [
["omitted", undefined],
["null", null],
["boolean", true],
["array", ["people"]],
["object", { toString: () => "people" }],
] as const) {
test(`rejects ${name} ${field} identifiers without coercing them`, async () => {
const assets = {
...entry,
transport: "assets-v1" as const,
entry: `/api/plugins/${entry.namespace}/assets/${"b".repeat(64)}/index.mjs`,
};
const declaration =
field === "mentionProviders"
? { label: "People", search: async () => [] }
: { slot: "page", title: "People", mount: () => undefined };
const invalid = { ...declaration, ...(id === undefined ? {} : { id }) };
const importer = rs
.fn()
.mockResolvedValueOnce({
default: { ...extension, [field]: [invalid] },
})
.mockResolvedValueOnce({
default: {
...extension,
[field]: [{ ...declaration, id: "people" }],
},
});
const loaded = await loadFrontendExtensions(
[assets, assets],
rs.fn(),
importer,
);
expect(loaded[0]?.error).toBeTruthy();
expect(loaded[0]?.extension).toBeUndefined();
expect(loaded[1]?.extension?.[field]?.[0]?.id).toBe("people");
});
}
test(`accepts literal null and undefined slug strings for ${field}`, async () => {
const assets = {
...entry,
transport: "assets-v1" as const,
entry: `/api/plugins/${entry.namespace}/assets/${"b".repeat(64)}/index.mjs`,
};
const declarations = ["null", "undefined"].map((id) =>
field === "mentionProviders"
? { id, label: "People", search: async () => [] }
: { id, slot: "page", title: "People", mount: () => undefined },
);
const loaded = await loadFrontendExtensions(
[assets],
rs.fn(),
async () => ({
default: { ...extension, [field]: declarations },
}),
);
expect(loaded[0]?.error).toBeUndefined();
expect(loaded[0]?.extension?.[field]?.map((value) => value.id)).toEqual([
"null",
"undefined",
]);
});
}

View File

@ -0,0 +1,224 @@
import { afterEach, expect, it, rs } from "@rstest/core";
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
import type { LoadedContribution } from "@/core/extensions/registry";
import { useExtensionMentions } from "@/core/extensions/use-mentions";
let viewer = "alice";
let locale = "en-US";
let discoveryPending = false;
let discoveryError = false;
const search = rs.fn();
const initialEntries: LoadedContribution[] = [
{
namespace: "test.team",
viewer_id: viewer,
module: "team",
entry: null,
title: "Team",
description: "",
settings: { enabled: true },
extension: {
apiVersion: 1,
module: "team",
mentionProviders: [{ id: "members", label: "Members", search }],
},
},
];
let entries = initialEntries;
rs.mock("@/core/auth/AuthProvider", () => ({
useAuth: () => ({ user: { id: viewer } }),
}));
rs.mock("@/core/i18n/hooks", () => ({ useI18n: () => ({ locale }) }));
rs.mock("@/core/extensions/hooks", () => ({
useFrontendExtensions: () => ({
data: entries,
isPending: discoveryPending,
isError: discoveryError,
}),
}));
afterEach(() => {
cleanup();
search.mockReset();
viewer = "alice";
locale = "en-US";
discoveryPending = false;
discoveryError = false;
entries = initialEntries;
rs.restoreAllMocks();
});
it.each([
"empty",
"backend-only",
"no contribution",
"empty providers",
"disabled",
"other viewer",
])("settles immediately without a search timer for %s", (scenario) => {
const entry = initialEntries[0]!;
if (scenario === "empty") entries = [];
if (scenario === "backend-only")
entries = [{ ...entry, module: null, extension: undefined }];
if (scenario === "no contribution")
entries = [{ ...entry, extension: { apiVersion: 1, module: "team" } }];
if (scenario === "empty providers")
entries = [
{
...entry,
extension: { apiVersion: 1, module: "team", mentionProviders: [] },
},
];
if (scenario === "disabled")
entries = [{ ...entry, settings: { enabled: false } }];
if (scenario === "other viewer") entries = [{ ...entry, viewer_id: "bob" }];
const timer = rs.spyOn(globalThis, "setTimeout");
const { result, rerender } = renderHook(
({ query }) => useExtensionMentions(query, "thread"),
{ initialProps: { query: "a" } },
);
expect(result.current.items).toEqual([]);
expect(result.current.loading).toBe(false);
expect(result.current.failed).toBe(false);
rerender({ query: "al" });
expect(result.current.loading).toBe(false);
expect(timer.mock.calls.some(([, delay]) => delay === 150)).toBe(false);
expect(search).not.toHaveBeenCalled();
});
it("keeps discovery loading and failure states when no providers are known", () => {
entries = [];
discoveryPending = true;
const { result, rerender } = renderHook(() =>
useExtensionMentions("a", "thread"),
);
expect(result.current.loading).toBe(true);
discoveryPending = false;
discoveryError = true;
rerender();
expect(result.current.loading).toBe(false);
expect(result.current.failed).toBe(true);
});
it("clears a removed provider immediately and ignores its late response", async () => {
let release!: (value: { id: string; label: string }[]) => void;
search.mockImplementation(
() =>
new Promise((resolve) => {
release = resolve;
}),
);
const { result, rerender } = renderHook(() =>
useExtensionMentions("a", "thread"),
);
expect(result.current.loading).toBe(true);
await waitFor(() => expect(search).toHaveBeenCalledTimes(1));
entries = [];
rerender();
expect(result.current.loading).toBe(false);
expect(result.current.items).toEqual([]);
await act(async () => release([{ id: "late", label: "Late" }]));
expect(result.current.items).toEqual([]);
});
it("retains settled candidates during a query refresh without a picker-wide loading flash", async () => {
let release!: (value: { id: string; label: string }[]) => void;
search.mockResolvedValueOnce([{ id: "old", label: "Alice" }]);
search.mockImplementationOnce(
() =>
new Promise((resolve) => {
release = resolve;
}),
);
const { result, rerender } = renderHook(
({ query }) => useExtensionMentions(query, "thread"),
{ initialProps: { query: "a" } },
);
await waitFor(() => expect(result.current.items[0]?.id).toBe("old"));
rerender({ query: "al" });
expect(result.current.items[0]?.id).toBe("old");
expect(result.current.loading).toBe(false);
await waitFor(() => expect(search).toHaveBeenCalledTimes(2));
expect(result.current.items[0]?.id).toBe("old");
await act(async () => {
release([{ id: "new", label: "Alison" }]);
});
expect(result.current.items[0]?.id).toBe("new");
});
it.each(["viewer", "thread", "locale", "entries"])(
"clears settled candidates immediately when %s changes",
async (change) => {
search.mockResolvedValueOnce([{ id: "old", label: "Alice" }]);
search.mockImplementation(() => new Promise(() => undefined));
const { result, rerender } = renderHook(
({ threadId }) => useExtensionMentions("a", threadId),
{ initialProps: { threadId: "one" } },
);
await waitFor(() => expect(result.current.items[0]?.id).toBe("old"));
if (change === "viewer") viewer = "bob";
if (change === "locale") locale = "zh-CN";
if (change === "entries") entries = [...entries];
rerender({ threadId: change === "thread" ? "two" : "one" });
expect(result.current.items).toEqual([]);
expect(result.current.loading).toBe(change !== "viewer");
},
);
it("does not replace fresh same-context results with a late superseded query", async () => {
let release!: (value: { id: string; label: string }[]) => void;
search.mockResolvedValueOnce([{ id: "first", label: "First" }]);
search.mockImplementationOnce(
() =>
new Promise((resolve) => {
release = resolve;
}),
);
search.mockResolvedValueOnce([{ id: "latest", label: "Latest" }]);
const { result, rerender } = renderHook(
({ query }) => useExtensionMentions(query, "thread"),
{ initialProps: { query: "first" } },
);
await waitFor(() => expect(result.current.items[0]?.id).toBe("first"));
rerender({ query: "slow" });
await waitFor(() => expect(search).toHaveBeenCalledTimes(2));
rerender({ query: "latest" });
await waitFor(() => expect(result.current.items[0]?.id).toBe("latest"));
await act(async () => {
release([{ id: "stale", label: "Stale" }]);
});
expect(result.current.items[0]?.id).toBe("latest");
});
it("cancels old queries and fences results across query, thread, viewer and unmount", async () => {
let release!: (value: { id: string; label: string }[]) => void;
let signal!: AbortSignal;
search.mockImplementationOnce((_query, context) => {
signal = context.signal;
return new Promise((resolve) => {
release = resolve;
});
});
search.mockResolvedValue([{ id: "new", label: "Current" }]);
const { result, rerender, unmount } = renderHook(
({ query, threadId }) => useExtensionMentions(query, threadId),
{ initialProps: { query: "old", threadId: "one" } },
);
await waitFor(() => expect(search).toHaveBeenCalledTimes(1));
rerender({ query: "new", threadId: "two" });
expect(signal.aborted).toBe(true);
await waitFor(() => expect(result.current.items[0]?.id).toBe("new"));
await act(async () => {
release([{ id: "old", label: "Stale" }]);
});
expect(result.current.items[0]?.id).toBe("new");
viewer = "bob";
rerender({ query: "new", threadId: "two" });
expect(result.current.items).toEqual([]);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.items).toEqual([]);
entries = entries.map((entry) => ({ ...entry, viewer_id: viewer }));
rerender({ query: "last", threadId: "three" });
await waitFor(() => expect(search).toHaveBeenCalledTimes(3));
unmount();
expect(search.mock.calls[2]![1].signal.aborted).toBe(true);
});