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:
DanielWalnut
2026-06-09 23:07:17 +08:00
committed by GitHub
parent 18bbb82f07
commit 16391e35ab
31 changed files with 2758 additions and 57 deletions
+199
View File
@@ -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,
}) => {
+43
View File
@@ -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") {
@@ -11,6 +11,7 @@ import {
hasContent,
hasReasoning,
isAssistantMessageGroupStreaming,
stripUploadedFilesTag,
} from "@/core/messages/utils";
function aiMessage(content: string): Message {
@@ -173,6 +174,38 @@ describe("inline <think> tag splitting", () => {
});
});
describe("human message internal context stripping", () => {
test("strips slash skill activation context from display content", () => {
const content =
"<slash_skill_activation>\n<skill_content># Secret SKILL.md</skill_content>\n</slash_skill_activation>\nreal user task";
expect(stripUploadedFilesTag(content)).toBe("real user task");
});
test("hides leaked slash skill activation messages with no user text", () => {
const messages = [
{
id: "slash-activation",
type: "human",
content:
"<slash_skill_activation>\n<skill_content># Secret SKILL.md</skill_content>\n</slash_skill_activation>",
},
{
id: "ai-1",
type: "ai",
content: "Public answer",
},
] as Message[];
const groups = getMessageGroups(messages);
expect(groups.map((group) => group.type)).toEqual(["assistant"]);
expect(
groups.flatMap((group) => group.messages).map((message) => message.id),
).toEqual(["ai-1"]);
});
});
test("hides internal todo reminder messages from message groups", () => {
const messages = [
{
@@ -260,6 +260,22 @@ describe("formatThreadAsJSON", () => {
expect(raw).toContain("real user text");
});
it("strips <slash_skill_activation> as defence in depth", () => {
// Slash activation normally rides in a hidden HumanMessage. If a replay
// or state merge loses the flag, export must still not leak full SKILL.md
// content into a user-visible transcript.
const leaky = human("real user task", {
id: "leak-slash-skill",
content:
"<slash_skill_activation>\n<skill_content># Secret SKILL.md\nUse internal source.</skill_content>\n</slash_skill_activation>\nreal user task",
} as unknown as Partial<Message>);
const raw = formatThreadAsJSON(makeThread(), [leaky]);
expect(raw).not.toContain("<slash_skill_activation>");
expect(raw).not.toContain("Secret SKILL.md");
expect(raw).not.toContain("internal source");
expect(raw).toContain("real user task");
});
it("sanitises tool message content when includeToolMessages is true", () => {
const message = {
id: "t-leak",