Compare commits

...

1 Commits

Author SHA1 Message Date
AmAzing129 43cb871cc1 🐛 fix(skill): prevent SKILL frontmatter from rendering twice 2026-05-24 19:43:56 +08:00
7 changed files with 136 additions and 44 deletions
@@ -31,6 +31,13 @@ const createAgentDocument = (overrides: Record<string, unknown> = {}) =>
...overrides,
}) as any;
const skillContent = (name = 'writer', body = '# Skill') => `---
name: ${name}
description: Writes content
---
${body}`;
describe('Agent skill VFS providers', () => {
const agentDocumentModel = {
associate: vi.fn(),
@@ -131,9 +138,11 @@ describe('Agent skill VFS providers', () => {
documentService,
});
const content = skillContent();
const result = await provider.create({
agentId: 'agent-1',
content: '# Skill',
content,
skillName: 'writer',
targetNamespace: 'agent',
});
@@ -148,26 +157,21 @@ describe('Agent skill VFS providers', () => {
templateId: 'agent-skill',
title: 'writer',
});
expect(agentDocumentModel.create).toHaveBeenNthCalledWith(
2,
'agent-1',
'SKILL.md',
'# Skill',
{
editorData: { markdown: '# Skill' },
fileType: 'skills/index',
metadata: { skill: { vfs: true } },
parentId: 'bundle-1',
policyLoad: 'disabled',
source: 'agent-signal:skill-management',
sourceType: 'agent-signal',
templateId: 'agent-skill',
title: 'SKILL.md',
},
);
expect(agentDocumentModel.create).toHaveBeenNthCalledWith(2, 'agent-1', 'SKILL.md', content, {
editorData: { markdown: '# Skill' },
fileType: 'skills/index',
metadata: { skill: { vfs: true } },
parentId: 'bundle-1',
policyLoad: 'disabled',
source: 'agent-signal:skill-management',
sourceType: 'agent-signal',
templateId: 'agent-skill',
title: 'SKILL.md',
});
expect(documentService.createDocument).not.toHaveBeenCalled();
expect(agentDocumentModel.associate).not.toHaveBeenCalled();
expect(result.path).toBe('./lobe/skills/agent/skills/writer/SKILL.md');
expect(result.content).toBe(content);
});
/**
@@ -235,9 +239,11 @@ describe('Agent skill VFS providers', () => {
documentService,
});
const content = skillContent('skill-a', '# Updated Skill');
const result = await provider.update({
agentId: 'agent-1',
content: 'new content',
content,
path: './lobe/skills/agent/skills/skill-a/SKILL.md',
});
@@ -246,10 +252,10 @@ describe('Agent skill VFS providers', () => {
'llm_call',
);
expect(agentDocumentModel.update).toHaveBeenCalledWith('agent-doc-file', {
content: 'new content',
editorData: { markdown: 'new content' },
content,
editorData: { markdown: '# Updated Skill' },
});
expect(result.content).toBe('new content');
expect(result.content).toBe(content);
});
it('soft-deletes the folder subtree for an agent skill', async () => {
@@ -1,5 +1,6 @@
import { createMarkdownEditorSnapshot } from '@/server/services/agentDocuments/headlessEditor';
import { AgentDocumentVfsError } from '@/server/services/agentDocumentVfs/errors';
import { extractSkillIndexBody } from '@/server/services/skillManagement/frontmatter';
import type {
CreateSkillInput,
@@ -119,19 +120,19 @@ export class ProviderSkillsAgentDocument implements WritableSkillMountProvider {
throw new AgentDocumentVfsError('Skill already exists', 'CONFLICT');
}
const snapshot = await createMarkdownEditorSnapshot(input.content);
const editorSnapshot = await createMarkdownEditorSnapshot(extractSkillIndexBody(input.content));
await createSkillTree({
agentDocumentModel: this.deps.agentDocumentModel,
agentId: input.agentId,
content: snapshot.content,
editorData: snapshot.editorData,
content: input.content,
editorData: editorSnapshot.editorData,
namespace: this.config.namespace,
skillName,
});
return buildSkillFileNode({
content: snapshot.content,
content: input.content,
namespace: this.config.namespace,
skillName,
});
@@ -143,9 +144,9 @@ export class ProviderSkillsAgentDocument implements WritableSkillMountProvider {
const documents = await this.deps.agentDocumentModel.findByAgent(input.agentId);
const document = assertSkillDocument(getSkillFile(documents, this.config.namespace, skillName));
const existingContent = await projectDocumentContent(document);
const snapshot = await createMarkdownEditorSnapshot(input.content);
const editorSnapshot = await createMarkdownEditorSnapshot(extractSkillIndexBody(input.content));
if (existingContent !== snapshot.content) {
if (existingContent !== input.content) {
await this.deps.documentService.trySaveCurrentDocumentHistory(
document.documentId,
'llm_call',
@@ -153,12 +154,12 @@ export class ProviderSkillsAgentDocument implements WritableSkillMountProvider {
}
await this.deps.agentDocumentModel.update(document.id, {
content: snapshot.content,
editorData: snapshot.editorData,
content: input.content,
editorData: editorSnapshot.editorData,
});
return buildSkillFileNode({
content: snapshot.content,
content: input.content,
namespace: this.config.namespace,
skillName,
});
@@ -331,7 +331,7 @@ describe('SkillManagementDocumentService', () => {
content: skillContent('release-writer', 'Writes release notes'),
filename: SKILL_INDEX_FILENAME,
params: expect.objectContaining({
editorData: expectedEditorData(skillContent('release-writer', 'Writes release notes')),
editorData: expectedEditorData(skillBody()),
fileType: SKILL_INDEX_FILE_TYPE,
parentId: 'document-1',
policyLoad: PolicyLoad.DISABLED,
@@ -380,7 +380,7 @@ describe('SkillManagementDocumentService', () => {
expect(agentDocumentModel.documents).toHaveLength(2);
expect(agentDocumentModel.documents.find((doc) => doc.id === source.id)).toEqual(
expect.objectContaining({
editorData: expectedEditorData(skillContent('draft-skill', 'Draft helper', '# Draft')),
editorData: expectedEditorData(skillBody('# Draft')),
fileType: SKILL_INDEX_FILE_TYPE,
filename: SKILL_INDEX_FILENAME,
parentId: detail.bundle.documentId,
@@ -485,9 +485,7 @@ describe('SkillManagementDocumentService', () => {
expect.objectContaining({
agentDocumentId: created.index.agentDocumentId,
params: expect.objectContaining({
editorData: expectedEditorData(
skillContent('researcher', 'Researches docs better', '# Better'),
),
editorData: expectedEditorData(skillBody('# Better')),
metadata: {
skill: { frontmatter: { description: 'Researches docs better', name: 'researcher' } },
},
@@ -580,7 +578,7 @@ describe('SkillManagementDocumentService', () => {
expect.objectContaining({
agentDocumentId: created.index.agentDocumentId,
params: expect.objectContaining({
editorData: expectedEditorData(skillContent('new-skill', 'Old description')),
editorData: expectedEditorData(skillBody()),
}),
}),
);
@@ -15,6 +15,7 @@ import {
SKILL_MANAGEMENT_SOURCE_TYPE,
} from './constants';
import {
extractSkillIndexBody,
normalizeSkillIndexContent,
parseSkillFrontmatter,
renderSkillIndexContent,
@@ -132,12 +133,15 @@ export class SkillManagementDocumentService {
const metadata = buildSkillMetadata(frontmatter);
// NOTICE:
// Managed skill indexes are Markdown-backed, but document history snapshots are
// editor-data-backed. Always keep `content` and `editorData` in sync so the next
// automated skill refinement can save a pre-mutation `document_histories` row.
// editor-data-backed. Keep `content` as the full SKILL.md source while projecting
// only the editable body into `editorData`; the frontmatter is rendered separately
// in the document header.
// Root cause: older managed-skill writes only persisted Markdown content, which made
// `DocumentService.trySaveCurrentDocumentHistory` no-op for `editorData = NULL`.
// Removal condition: only if document history can snapshot Markdown content directly.
const indexEditorSnapshot = await this.createMarkdownEditorSnapshot(normalizedContent);
const indexEditorSnapshot = await this.createMarkdownEditorSnapshot(
extractSkillIndexBody(normalizedContent),
);
const duplicateBundles = (
await this.agentDocumentModel.listByParentAndFilename(input.agentId, null, name)
).filter((doc) => doc.fileType === SKILL_BUNDLE_FILE_TYPE);
@@ -351,11 +355,12 @@ export class SkillManagementDocumentService {
const metadata = buildSkillMetadata(frontmatter);
// NOTICE:
// The replacement body must be projected into editor data before updating the backing
// document. Without this, history capture works for ordinary agent documents but silently
// skips managed skills because there is no valid editor state to snapshot.
// document. The YAML frontmatter stays in `content` and the metadata card, not the editor.
// Root cause: `document_histories.editor_data` stores editor snapshots, not Markdown.
// Removal condition: only if document history can snapshot Markdown content directly.
const editorSnapshot = await this.createMarkdownEditorSnapshot(normalizedContent);
const editorSnapshot = await this.createMarkdownEditorSnapshot(
extractSkillIndexBody(normalizedContent),
);
await this.documentService.trySaveCurrentDocumentHistory(index.documentId, 'llm_call');
const updatedBundle =
@@ -402,7 +407,9 @@ export class SkillManagementDocumentService {
});
const frontmatter = parseSkillFrontmatter(normalizedContent);
const metadata = buildSkillMetadata(frontmatter);
const editorSnapshot = await this.createMarkdownEditorSnapshot(normalizedContent);
const editorSnapshot = await this.createMarkdownEditorSnapshot(
extractSkillIndexBody(normalizedContent),
);
if (normalizedContent !== index.content) {
await this.documentService.trySaveCurrentDocumentHistory(index.documentId, 'llm_call');
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import {
extractSkillIndexBody,
normalizeSkillIndexContent,
parseSkillFrontmatter,
validateSkillName,
@@ -112,3 +113,11 @@ describe('normalizeSkillIndexContent', () => {
).toThrow('Skill frontmatter description is required');
});
});
describe('extractSkillIndexBody', () => {
it('returns only the editable markdown body', () => {
expect(
extractSkillIndexBody('---\nname: skill-name\ndescription: Skill description\n---\n# Body'),
).toBe('# Body');
});
});
@@ -178,6 +178,19 @@ export const normalizeSkillIndexContent = (input: NormalizeSkillIndexContentInpu
return body.endsWith('\n') ? serialized : serialized.replace(/\n$/, '');
};
/**
* Extracts the editable Markdown body from a normalized skill index document.
*
* `documents.content` keeps the full `SKILL.md` source including frontmatter, while editor
* snapshots must represent only the user-editable body so the metadata block is not rendered
* twice in the page editor.
*/
export const extractSkillIndexBody = (content: string): string => {
const { body } = parseMatter(content);
return body.replace(/^\r?\n/, '');
};
const readSkillFrontmatterFields = (data: Record<string, unknown>): Partial<SkillFrontmatter> => {
const name = typeof data.name === 'string' ? data.name.trim() : undefined;
const description = typeof data.description === 'string' ? data.description.trim() : undefined;
@@ -275,6 +275,64 @@ name: skill-name
expect(mockEditor.setDocument).toHaveBeenCalledWith('json', JSON.stringify(editorData));
});
it('should preserve legacy SKILL.md editorData without client-side migration', () => {
const { result } = renderHook(() => useDocumentStore());
const mockEditor = createMockEditor() as any;
const editorData = {
root: {
children: [
{ id: '1', type: 'horizontalrule' },
{
children: [
{
text: `description: >-
Use when a production API returns a misleading 400 validation error.
name: production-env-var-api-triage`,
type: 'text',
},
],
tag: 'h2',
type: 'heading',
},
{
children: [
{ children: [{ text: 'original', type: 'text' }], type: 'paragraph' },
{ children: [{ text: 'updated', type: 'text' }], type: 'paragraph' },
],
diffType: 'modify',
id: 'body-diff',
type: 'diff',
},
],
type: 'root',
},
};
act(() => {
result.current.initDocumentWithEditor({
content: `---
description: >-
Use when a production API returns a misleading 400 validation error.
name: production-env-var-api-triage
---
# Production API Error Triage`,
contentFormat: 'skillMarkdown',
documentId: 'doc-1',
editor: mockEditor,
editorData,
sourceType: 'notebook',
});
});
act(() => {
result.current.onEditorInit(mockEditor);
});
expect(mockEditor.setDocument).toHaveBeenCalledWith('json', JSON.stringify(editorData));
expect(mockEditor.setDocument).not.toHaveBeenCalledWith('markdown', expect.any(String));
});
it('should fall back to editable body when SKILL.md editorData cannot be loaded', () => {
const { result } = renderHook(() => useDocumentStore());
const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {});