mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-06-10 17:35:57 +00:00
fix(skills): harden slash skill activation across chat channels (#3466)
* support slash skill activation * format slash skill activation * Preserve slash skill activation with uploads * Address slash skill review feedback * Address slash skill follow-up review * Fix lazy slash skill storage resolution * Keep slash skill activation out of system prompt * Address slash skill review issues * fix: harden slash skill command handling * feat(frontend): add slash skill autocomplete * fix: address slash skill review feedback * fix: preserve slash skill text for IM uploads
This commit is contained in:
@@ -24,6 +24,61 @@ test.describe("Chat workspace", () => {
|
||||
await expect(textarea).toHaveValue("Hello, DeerFlow!");
|
||||
});
|
||||
|
||||
test("suggests matching skills after a leading slash", async ({ page }) => {
|
||||
await page.goto("/workspace/chats/new");
|
||||
|
||||
const textarea = page.getByPlaceholder(/how can i assist you/i);
|
||||
await expect(textarea).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await textarea.fill("/dat");
|
||||
await expect(
|
||||
page.getByRole("option", { name: /data-analysis/i }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("option", { name: /disabled-skill/i }),
|
||||
).toBeHidden();
|
||||
|
||||
await textarea.press("Enter");
|
||||
|
||||
await expect(textarea).toHaveValue("/data-analysis ");
|
||||
});
|
||||
|
||||
test("keeps Shift+Enter as newline while skill suggestions are visible", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/workspace/chats/new");
|
||||
|
||||
const textarea = page.getByPlaceholder(/how can i assist you/i);
|
||||
await expect(textarea).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await textarea.fill("/dat");
|
||||
await expect(
|
||||
page.getByRole("option", { name: /data-analysis/i }),
|
||||
).toBeVisible();
|
||||
|
||||
await textarea.press("Shift+Enter");
|
||||
|
||||
await expect(textarea).toHaveValue("/dat\n");
|
||||
await expect(
|
||||
page.getByRole("option", { name: /data-analysis/i }),
|
||||
).toBeHidden();
|
||||
});
|
||||
|
||||
test("does not suggest skills for slash text away from the prompt start", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/workspace/chats/new");
|
||||
|
||||
const textarea = page.getByPlaceholder(/how can i assist you/i);
|
||||
await expect(textarea).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await textarea.fill("please /dat");
|
||||
|
||||
await expect(
|
||||
page.getByRole("option", { name: /data-analysis/i }),
|
||||
).toBeHidden();
|
||||
});
|
||||
|
||||
test("sending a message triggers API call and shows response", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -49,6 +104,150 @@ test.describe("Chat workspace", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("slash skill command is submitted as normal chat text", async ({
|
||||
page,
|
||||
}) => {
|
||||
const slashCommand = "/data-analysis analyze uploads/foo.csv";
|
||||
let submittedText: string | undefined;
|
||||
await page.route("**/runs/stream", (route) => {
|
||||
const body = route.request().postDataJSON() as {
|
||||
input?: { messages?: Array<{ content?: unknown }> };
|
||||
};
|
||||
const content = body.input?.messages?.at(-1)?.content;
|
||||
if (typeof content === "string") {
|
||||
submittedText = content;
|
||||
} else if (Array.isArray(content)) {
|
||||
submittedText = content
|
||||
.map((block) =>
|
||||
typeof block === "object" &&
|
||||
block !== null &&
|
||||
"text" in block &&
|
||||
typeof block.text === "string"
|
||||
? block.text
|
||||
: "",
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
return handleRunStream(route);
|
||||
});
|
||||
|
||||
await page.goto("/workspace/chats/new");
|
||||
|
||||
const textarea = page.getByPlaceholder(/how can i assist you/i);
|
||||
await expect(textarea).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await textarea.fill(slashCommand);
|
||||
await textarea.press("Enter");
|
||||
|
||||
await expect
|
||||
.poll(() => submittedText, { timeout: 10_000 })
|
||||
.toBe(slashCommand);
|
||||
await expect(page.getByText("Hello from DeerFlow!")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("slash skill command with attachment preserves command text and file metadata", async ({
|
||||
page,
|
||||
}) => {
|
||||
const slashCommand = "/data-analysis analyze report.docx";
|
||||
let uploadCalled = false;
|
||||
let submittedText: string | undefined;
|
||||
let submittedFiles:
|
||||
| Array<{ filename?: string; path?: string; status?: string }>
|
||||
| undefined;
|
||||
|
||||
await page.route("**/api/threads/*/uploads", async (route) => {
|
||||
uploadCalled = true;
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
message: "Uploaded",
|
||||
files: [
|
||||
{
|
||||
filename: "report.docx",
|
||||
size: 12,
|
||||
path: "report.docx",
|
||||
virtual_path: "/mnt/user-data/uploads/report.docx",
|
||||
artifact_url: "/api/threads/test/uploads/report.docx",
|
||||
extension: ".docx",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/runs/stream", (route) => {
|
||||
const body = route.request().postDataJSON() as {
|
||||
input?: {
|
||||
messages?: Array<{
|
||||
content?: unknown;
|
||||
additional_kwargs?: {
|
||||
files?: Array<{
|
||||
filename?: string;
|
||||
path?: string;
|
||||
status?: string;
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
};
|
||||
const message = body.input?.messages?.at(-1);
|
||||
const content = message?.content;
|
||||
if (typeof content === "string") {
|
||||
submittedText = content;
|
||||
} else if (Array.isArray(content)) {
|
||||
submittedText = content
|
||||
.map((block) =>
|
||||
typeof block === "object" &&
|
||||
block !== null &&
|
||||
"text" in block &&
|
||||
typeof block.text === "string"
|
||||
? block.text
|
||||
: "",
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
submittedFiles = message?.additional_kwargs?.files;
|
||||
return handleRunStream(route);
|
||||
});
|
||||
|
||||
await page.goto("/workspace/chats/new");
|
||||
|
||||
const textarea = page.getByPlaceholder(/how can i assist you/i);
|
||||
await expect(textarea).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await page.getByLabel("Upload files").setInputFiles({
|
||||
name: "report.docx",
|
||||
mimeType:
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
buffer: Buffer.from("fake docx"),
|
||||
});
|
||||
|
||||
await textarea.fill(slashCommand);
|
||||
await textarea.press("Enter");
|
||||
|
||||
await expect.poll(() => uploadCalled, { timeout: 10_000 }).toBeTruthy();
|
||||
await expect
|
||||
.poll(() => submittedText, { timeout: 10_000 })
|
||||
.toBe(slashCommand);
|
||||
await expect
|
||||
.poll(() => submittedFiles, { timeout: 10_000 })
|
||||
.toEqual([
|
||||
{
|
||||
filename: "report.docx",
|
||||
size: 12,
|
||||
path: "/mnt/user-data/uploads/report.docx",
|
||||
status: "uploaded",
|
||||
},
|
||||
]);
|
||||
await expect(page.getByText("Hello from DeerFlow!")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps attachments visible while upload submit is pending", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -35,11 +35,41 @@ export type MockAgent = {
|
||||
system_prompt?: string;
|
||||
};
|
||||
|
||||
export type MockSkill = {
|
||||
name: string;
|
||||
description: string;
|
||||
category?: string;
|
||||
license?: string | null;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export type MockAPIOptions = {
|
||||
threads?: MockThread[];
|
||||
agents?: MockAgent[];
|
||||
skills?: MockSkill[];
|
||||
};
|
||||
|
||||
const DEFAULT_SKILLS: MockSkill[] = [
|
||||
{
|
||||
name: "data-analysis",
|
||||
description: "Analyze structured data and produce charts.",
|
||||
category: "public",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
name: "frontend-design",
|
||||
description: "Create polished frontend interfaces.",
|
||||
category: "public",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
name: "disabled-skill",
|
||||
description: "Hidden from slash autocomplete.",
|
||||
category: "public",
|
||||
enabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// mockLangGraphAPI
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -52,6 +82,7 @@ export type MockAPIOptions = {
|
||||
export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
||||
const threads = options?.threads ?? [];
|
||||
const agents = options?.agents ?? [];
|
||||
const skills = options?.skills ?? DEFAULT_SKILLS;
|
||||
|
||||
// Thread search — sidebar thread list & chats list page
|
||||
void page.route("**/api/langgraph/threads/search", (route) => {
|
||||
@@ -259,6 +290,18 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
|
||||
return route.fallback();
|
||||
});
|
||||
|
||||
// Skills list — settings page and slash autocomplete
|
||||
void page.route("**/api/skills", (route) => {
|
||||
if (route.request().method() === "GET") {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ skills }),
|
||||
});
|
||||
}
|
||||
return route.fallback();
|
||||
});
|
||||
|
||||
// Follow-up suggestions — input box auto-suggest after AI response
|
||||
void page.route("**/api/threads/*/suggestions", (route) => {
|
||||
if (route.request().method() === "POST") {
|
||||
|
||||
Reference in New Issue
Block a user