feat(ai): add read-only user skill loader for SKILL.md

Refs: #9363
This commit is contained in:
Abeautifulsnow authored and GitHub committed 2026-09-22 02:24:23 +08:00
1 parent 6af337e67c
commit 34d8811cd3
36 files changed
+1765 -26

No files matched your search

+9 -12
View File
@@ -32,11 +32,7 @@ function scriptSetupContent(): string {
function functionBody(script: string, signature: string): string {
const sigIndex = script.indexOf(signature);
assert.notEqual(sigIndex, -1, `expected to find ${signature}`);
assert.equal(
script.indexOf(signature, sigIndex + signature.length),
-1,
`${signature} should be unique`,
);
assert.equal(script.indexOf(signature, sigIndex + signature.length), -1, `${signature} should be unique`);
const openBrace = script.indexOf("{", sigIndex + signature.length);
assert.notEqual(openBrace, -1, "function body should open with a brace");
@@ -72,11 +68,15 @@ test("send() acquires the guard synchronously before the first await", () => {
assert.ok(entryCheck < guardSet, "isGenerating check must precede the guard set");
assert.ok(guardSet < firstAwait, "guard must be set before the first await (no await between check and set)");
// The first suspension point is specifically the prompt-template load — the
// await the reviewer flagged as happening before the guard existed.
// The first suspension point is the send-time skill read or the prompt-template
// load — both run after the guard and both are pipeline-entry awaits whose
// failure paths release the guard state explicitly (see the early returns
// after `aiGenerationGuard.begin()`).
const ensureLoadedAwait = body.indexOf("await promptTemplateStore.ensureLoaded()");
assert.notEqual(ensureLoadedAwait, -1, "send() should await promptTemplateStore.ensureLoaded()");
assert.equal(firstAwait, ensureLoadedAwait, "ensureLoaded() must be the first await in send()");
const skillReadAwait = body.indexOf("await readUserSkills([...selectedSkillIds.value], skillRootSettings())");
const pipelineEntryAwaits = [ensureLoadedAwait, skillReadAwait].filter((index) => index >= 0);
assert.ok(pipelineEntryAwaits.includes(firstAwait), "the first await in send() must be the skill read or the prompt-template load");
});
test("send() never leaks the guard on an early return after acquiring it", () => {
@@ -93,10 +93,7 @@ test("send() never leaks the guard on an early return after acquiring it", () =>
for (const match of returnsAfterGuard) {
const preceding = tail.slice(0, match.index);
assert.ok(
preceding.includes("isGenerating.value = false"),
"any early return after the guard is acquired must reset isGenerating first",
);
assert.ok(preceding.includes("isGenerating.value = false"), "any early return after the guard is acquired must reset isGenerating first");
}
});
+51
View File
@@ -0,0 +1,51 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "vitest";
// AiAssistant.vue has no mounted-DOM harness in this repo (no @vue/test-utils),
// so this pins the /skill contract on the compiled SFC source, the same way
// aiAssistantComposerLayout.test.ts pins the composer layout.
const source = readFileSync(new URL("../../apps/desktop/src/components/editor/AiAssistant.vue", import.meta.url), "utf8");
function section(startMarker: string, endMarker: string): string {
const start = source.indexOf(startMarker);
assert.notEqual(start, -1, `missing marker: ${startMarker}`);
const end = source.indexOf(endMarker, start + startMarker.length);
assert.notEqual(end, -1, `missing end marker: ${endMarker}`);
return source.slice(start, end);
}
test("/skill rides its own command source instead of the action lists", () => {
const commands = section("const filteredCommands = computed", "const AI_SQL_FILE_MENTION_CANDIDATE_LIMIT");
assert.ok(commands.includes('{ type: "skills" }'), "filteredCommands must merge a dedicated skills entry");
assert.ok(commands.includes('type: "action"'), "action entries must stay in the same command list");
// The built-in action arrays must not gain a skill entry: they also feed the
// mode+action picker, which would then offer /skill as a selectable action.
for (const marker of ["/** Ask-mode actions: SQL-producing, never auto-run. */", "/** Agent-mode actions: task-oriented, drive tool use and real results. */"]) {
const list = section(marker, "];");
assert.ok(!/skill/i.test(list.replace(/AiActionButton/g, "")), `action list must not contain a skill entry: ${marker}`);
}
});
test("selecting /skill never changes the built-in action", () => {
const body = section("function selectCommand(command: AiSlashEntry)", "\nfunction insertMention");
const skillsBranch = body.indexOf('command.type === "skills"');
const actionAssign = body.indexOf("activeAction.value = command.button.action");
assert.notEqual(skillsBranch, -1, "selectCommand must branch on the skills entry");
assert.notEqual(actionAssign, -1, "selectCommand must still assign the chosen action");
assert.ok(skillsBranch < actionAssign, "the skills branch must be evaluated before any action assignment");
const branch = body.slice(skillsBranch, actionAssign);
assert.ok(branch.includes("showSkillSelector.value = true"), "the skills branch must open the selector");
assert.ok(branch.includes("return"), "the skills branch must return before the action assignment");
assert.ok(!branch.includes("activeAction"), "the skills branch must not touch activeAction");
});
test("the skills selector only ever lists metadata", () => {
const selector = section('<Popover v-model:open="showSkillSelector">', 'v-if="mentionOpen"');
assert.ok(selector.length > 0);
assert.ok(selector.includes("userSkillStore.groupedSkills"), "the selector renders the metadata catalog");
assert.ok(!selector.includes("readUserSkills"), "the selector must never read skill bodies");
assert.ok(selector.includes("refreshSkills"), "the selector exposes Refresh");
});
+77
View File
@@ -0,0 +1,77 @@
import assert from "node:assert/strict";
import { beforeEach, test, vi } from "vitest";
import { createPinia, setActivePinia } from "pinia";
import type { UserSkillRootSettings, UserSkillsListResult } from "../../apps/desktop/src/types/userSkills";
const apiMock = vi.hoisted(() => ({
listUserSkills: vi.fn(),
readUserSkills: vi.fn(),
}));
vi.mock("@/lib/backend/api", () => apiMock);
import { useUserSkillStore } from "../../apps/desktop/src/stores/userSkillStore.ts";
const rootSettings: UserSkillRootSettings = { customRootEnabled: true, customRoot: "D:/Team/dbx-skills" };
function listing(status: "ok" | "missing" | "invalid", skills: Array<{ id: string; name: string; description: string }>) {
return { status, skills };
}
beforeEach(() => {
setActivePinia(createPinia());
apiMock.listUserSkills.mockReset();
apiMock.readUserSkills.mockReset();
});
test("refresh groups custom root first and tracks statuses", async () => {
apiMock.listUserSkills.mockResolvedValueOnce({
defaultRoot: listing("ok", [
{ id: "d-2", name: "Zeta", description: "z" },
{ id: "d-1", name: "Alpha", description: "a" },
]),
customRoot: listing("ok", [{ id: "c-1", name: "Team Rules", description: "t" }]),
} satisfies UserSkillsListResult);
const store = useUserSkillStore();
assert.equal(await store.refresh(rootSettings), true);
assert.deepEqual(
store.groupedSkills.map((group) => group.source),
["custom", "default"],
);
assert.equal(store.totalCount, 3);
assert.equal(store.metaFor("c-1")?.name, "Team Rules");
assert.equal(store.defaultRootStatus, "ok");
assert.equal(store.customRootStatus, "ok");
assert.equal(apiMock.listUserSkills.mock.calls[0][0], rootSettings);
});
test("absent default root and disabled custom root yield empty groups, not errors", async () => {
apiMock.listUserSkills.mockResolvedValueOnce({
defaultRoot: listing("missing", []),
customRoot: null,
} satisfies UserSkillsListResult);
const store = useUserSkillStore();
assert.equal(await store.refresh({ customRootEnabled: false, customRoot: null }), true);
assert.equal(store.totalCount, 0);
assert.deepEqual(store.groupedSkills, []);
assert.equal(store.lastError, null);
assert.equal(store.hasLoadedOnce, true);
});
test("refresh failure keeps prior catalog and records retryable error", async () => {
apiMock.listUserSkills.mockResolvedValueOnce({
defaultRoot: listing("ok", [{ id: "d-1", name: "Alpha", description: "a" }]),
customRoot: null,
} satisfies UserSkillsListResult);
const store = useUserSkillStore();
await store.refresh(rootSettings);
apiMock.listUserSkills.mockRejectedValueOnce(new Error("backend unavailable"));
assert.equal(await store.refresh(rootSettings), false);
assert.equal(store.lastError, "backend unavailable");
// Prior catalog remains so chips keep their display metadata.
assert.equal(store.totalCount, 1);
assert.equal(store.metaFor("d-1")?.name, "Alpha");
});